Compare commits

...

6 Commits

Author SHA1 Message Date
owen dcf10d85b3 assistant: reply 'images not supported' and skip images
The cog's vision_model defaulted to the text-only llama3.2:3b, so any image in
the message/reply was sent to a non-vision model, causing Ollama 400 'invalid
image input'. Now the bot tells the user images aren't supported and continues
with text-only, preserving normal chat.
2026-09-22 23:02:09 -05:00
owen b8eb40bc64 ytaudio: accurate active queue system
Replace the drained asyncio.Queue with an explicit, always-inspectable
model: GuildPlayer.pending (waiting tracks) + current (now playing).
- queue command shows Now playing plus numbered Up next list
- play reports queue position when adding behind an active track
- stop/leave use clear_pending(); skip relies on the TRACK_END event to
  advance exactly one track (no double-skip)
- queue alias q, nowplaying alias np
2026-09-17 17:44:50 -05:00
owen 70da956fd4 ytaudio: add [p]summon to join VC; revert tts join change
Add a summon command (alias ytjoin) that joins the caller's voice
channel, replacing the old Audio-cog summon. _ensure_connected now
moves the bot with Player.move_to when it is already in a different
channel. Revert the ttstoy tts join back to the simple connect.
2026-09-17 17:33:23 -05:00
owen 03e8589918 ttstoy: make tts join behave as a summon
Connect to the caller's voice channel when the bot is not in voice, and
move to the caller's channel if the bot is currently in a different one
(via Player.move_to). Previously it only connected when the bot had no
voice state, so it would not follow the user between channels.
2026-09-17 17:29:36 -05:00
owen 26c0b15513 ytaudio: live download-progress indicator, plain text only
Post a status message on play and live-edit it with the download
percentage via a throttled yt-dlp progress_hook (max one edit/sec,
scheduled onto the event loop from the executor thread). Shows
Downloading (N%), then Converting to mp3, then Now playing. Removed all
emoji and em-dashes from user-facing strings and comments.
2026-09-17 17:11:59 -05:00
owen 04558151e8 ytaudio: rename join/leave -> ytjoin/ytleave to avoid command collisions
The Minecraft cog already registers top-level 'join' and 'leave', which
caused a CommandRegistrationError. Namespace these two commands under a
yt-prefix (ytjoin, ytleave, alias ytdc).
2026-09-17 17:04:23 -05:00
2 changed files with 172 additions and 63 deletions
+10
View File
@@ -138,6 +138,16 @@ class Assistant(commands.Cog):
if not content and not image_urls: if not content and not image_urls:
return return
# Vision is not supported by the configured text-only model. If the user
# sent image(s), tell them once and skip the images, continuing with any
# text so normal chat keeps working (avoids Ollama 400 "invalid image
# input" from sending images to a non-vision model).
if image_urls:
await message.reply("Images not supported at this time.")
image_urls = []
if not content:
return
if question_mode and not content.rstrip().endswith("?"): if question_mode and not content.rstrip().endswith("?"):
if is_ping: if is_ping:
await message.reply("Question mode is on - messages must end with **?**.") await message.reply("Question mode is on - messages must end with **?**.")
+162 -63
View File
@@ -1,5 +1,5 @@
""" """
YTAudio — YouTube audio for Red-DiscordBot via yt-dlp -> mp3 -> Lavalink (local source). YTAudio - YouTube audio for Red-DiscordBot via yt-dlp -> mp3 -> Lavalink (local source).
This cog OWNS the shared Red-Lavalink client connection: This cog OWNS the shared Red-Lavalink client connection:
* On startup it calls ``lavalink.initialize()`` (once the bot is ready), * On startup it calls ``lavalink.initialize()`` (once the bot is ready),
@@ -22,6 +22,7 @@ import functools
import logging import logging
import os import os
import shutil import shutil
import time as _time
from typing import Dict, List, Optional from typing import Dict, List, Optional
import discord import discord
@@ -62,48 +63,70 @@ YTDLP_BASE_OPTS = {
class QueuedTrack: class QueuedTrack:
"""A track queued for a guild, resolved lazily at play time.""" """A track queued for a guild, resolved lazily at play time."""
__slots__ = ("query", "requester", "title", "mp3_path") __slots__ = ("query", "requester", "channel", "title", "mp3_path", "status_msg")
def __init__(self, query: str, requester: discord.abc.User): def __init__(self, query: str, requester: discord.abc.User, channel):
self.query = query self.query = query
self.requester = requester self.requester = requester
self.channel = channel
self.title: Optional[str] = None self.title: Optional[str] = None
self.mp3_path: Optional[str] = None self.mp3_path: Optional[str] = None
self.status_msg = None
class GuildPlayer: class GuildPlayer:
"""Per-guild queue + player-loop state. One per guild.""" """Per-guild queue + player-loop state. One per guild.
The queue model is explicit and always inspectable:
* ``pending`` is the list of tracks waiting to play (index 0 is next).
* ``current`` is the track playing right now (or None).
Commands read these directly, so nowplaying/queue always reflect reality.
"""
def __init__(self, cog: "YTAudio", guild_id: int): def __init__(self, cog: "YTAudio", guild_id: int):
self.cog = cog self.cog = cog
self.guild_id = guild_id self.guild_id = guild_id
self.queue: "asyncio.Queue[Optional[QueuedTrack]]" = asyncio.Queue() self.pending: List[QueuedTrack] = []
self.current: Optional[QueuedTrack] = None self.current: Optional[QueuedTrack] = None
self.task: Optional[asyncio.Task] = None self.task: Optional[asyncio.Task] = None
self.next_event = asyncio.Event() self.next_event = asyncio.Event() # fired when the current track ends
self.wakeup = asyncio.Event() # fired when a track is added
self.volume = 100 self.volume = 100
def start(self): def start(self):
if self.task is None or self.task.done(): if self.task is None or self.task.done():
self.task = asyncio.create_task(self._player_loop()) self.task = asyncio.create_task(self._player_loop())
def enqueue(self, track: "QueuedTrack"):
self.pending.append(track)
self.wakeup.set()
def clear_pending(self):
cleared = self.pending
self.pending = []
for t in cleared:
self.cog._cleanup_file(t)
async def _player_loop(self): async def _player_loop(self):
while True: while True:
if not self.pending:
self.wakeup.clear()
if not self.pending:
try:
await self.wakeup.wait()
except asyncio.CancelledError:
return
continue
track = self.pending.pop(0)
self.current = track
self.next_event.clear() self.next_event.clear()
try:
track = await self.queue.get()
except asyncio.CancelledError:
return
if track is None: # shutdown sentinel
return
self.current = track
try: try:
await self.cog._play_track(self.guild_id, track, self.next_event) await self.cog._play_track(self.guild_id, track, self.next_event)
# Wait until the track-end handler fires next_event
await self.next_event.wait() await self.next_event.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
self.cog._cleanup_file(track) self.cog._cleanup_file(track)
self.current = None
return return
except Exception: except Exception:
log.exception("Error playing track in guild %s", self.guild_id) log.exception("Error playing track in guild %s", self.guild_id)
@@ -137,7 +160,7 @@ class YTAudio(commands.Cog):
async def _connect_lavalink(self): async def _connect_lavalink(self):
await self.bot.wait_until_red_ready() await self.bot.wait_until_red_ready()
# If a node is already connected (e.g. built-in Audio still loaded), # If a node is already connected (e.g. built-in Audio still loaded),
# don't double-initialize — just share it. # don't double-initialize, just share it.
if lavalink.get_all_nodes(): if lavalink.get_all_nodes():
self._ll_ready = True self._ll_ready = True
log.info("YTAudio: a Lavalink node is already connected; sharing it.") log.info("YTAudio: a Lavalink node is already connected; sharing it.")
@@ -195,10 +218,55 @@ class YTAudio(commands.Cog):
gp.next_event.set() gp.next_event.set()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# yt-dlp resolution (blocking -> executor) # yt-dlp resolution (blocking -> executor) with live progress
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _blocking_extract(self, query: str) -> dict: def _make_progress_hook(self, loop, track: QueuedTrack):
with yt_dlp.YoutubeDL(self._ytdlp_opts) as ydl: # Runs inside the executor thread. Schedules throttled message edits
# back onto the event loop. Discord rate-limits edits, so we edit at
# most once per second and only when the whole-number percent changes.
state = {"last_edit": 0.0, "last_pct": -1}
def hook(d):
if track.status_msg is None:
return
status = d.get("status")
if status == "downloading":
total = d.get("total_bytes") or d.get("total_bytes_estimate")
downloaded = d.get("downloaded_bytes") or 0
if not total:
return
pct = int(downloaded * 100 / total)
now = _time.monotonic()
if pct == state["last_pct"] or (now - state["last_edit"]) < 1.0:
if pct < 100:
return
state["last_pct"] = pct
state["last_edit"] = now
content = "Downloading: {} ({}%)".format(track.query, pct)
loop.call_soon_threadsafe(
lambda c=content: asyncio.ensure_future(self._safe_edit(track, c))
)
elif status == "finished":
content = "Converting to mp3: {}".format(track.query)
loop.call_soon_threadsafe(
lambda c=content: asyncio.ensure_future(self._safe_edit(track, c))
)
return hook
async def _safe_edit(self, track: QueuedTrack, content: str):
if track.status_msg is None:
return
try:
await track.status_msg.edit(content=content)
except Exception:
pass
def _blocking_extract(self, query: str, progress_hook=None) -> dict:
opts = dict(self._ytdlp_opts)
if progress_hook is not None:
opts["progress_hooks"] = [progress_hook]
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(query, download=True) info = ydl.extract_info(query, download=True)
if "entries" in info: # search/playlist -> first entry if "entries" in info: # search/playlist -> first entry
info = info["entries"][0] info = info["entries"][0]
@@ -206,8 +274,9 @@ class YTAudio(commands.Cog):
async def _resolve_to_mp3(self, track: QueuedTrack): async def _resolve_to_mp3(self, track: QueuedTrack):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
hook = self._make_progress_hook(loop, track)
info = await loop.run_in_executor( info = await loop.run_in_executor(
None, functools.partial(self._blocking_extract, track.query) None, functools.partial(self._blocking_extract, track.query, hook)
) )
track.title = info.get("title", track.query) track.title = info.get("title", track.query)
vid = info.get("id") vid = info.get("id")
@@ -226,6 +295,15 @@ class YTAudio(commands.Cog):
# Playback # Playback
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _play_track(self, guild_id: int, track: QueuedTrack, done_event: asyncio.Event): async def _play_track(self, guild_id: int, track: QueuedTrack, done_event: asyncio.Event):
status_msg = None
if track.channel is not None:
try:
status_msg = await track.channel.send(
"Downloading: {} (0%)".format(track.query)
)
except Exception:
status_msg = None
track.status_msg = status_msg
await self._resolve_to_mp3(track) await self._resolve_to_mp3(track)
player = lavalink.get_player(guild_id) player = lavalink.get_player(guild_id)
gp = self.players.get(guild_id) gp = self.players.get(guild_id)
@@ -234,11 +312,21 @@ class YTAudio(commands.Cog):
loaded = await player.load_tracks(track.mp3_path) loaded = await player.load_tracks(track.mp3_path)
if loaded.has_error or loaded.load_type != lavalink.enums.LoadType.TRACK_LOADED: if loaded.has_error or loaded.load_type != lavalink.enums.LoadType.TRACK_LOADED:
log.error("YTAudio: failed to load mp3 into Lavalink: %s", track.mp3_path) log.error("YTAudio: failed to load mp3 into Lavalink: %s", track.mp3_path)
if status_msg is not None:
try:
await status_msg.edit(content="Failed to load: {}".format(track.query))
except Exception:
pass
done_event.set() done_event.set()
return return
player.add(requester=track.requester, track=loaded.tracks[0]) player.add(requester=track.requester, track=loaded.tracks[0])
if not player.is_playing: if not player.is_playing:
await player.play() await player.play()
if status_msg is not None:
try:
await status_msg.edit(content="Now playing: {}".format(track.title or track.query))
except Exception:
pass
def _get_gp(self, guild_id: int) -> GuildPlayer: def _get_gp(self, guild_id: int) -> GuildPlayer:
gp = self.players.get(guild_id) gp = self.players.get(guild_id)
@@ -250,14 +338,22 @@ class YTAudio(commands.Cog):
async def _ensure_connected(self, ctx: commands.Context) -> bool: async def _ensure_connected(self, ctx: commands.Context) -> bool:
if not self._ll_ready and not lavalink.get_all_nodes(): if not self._ll_ready and not lavalink.get_all_nodes():
await ctx.send("❌ Lavalink is not connected yet. Try again in a moment.") await ctx.send("Lavalink is not connected yet. Try again in a moment.")
return False return False
if ctx.author.voice is None or ctx.author.voice.channel is None: if ctx.author.voice is None or ctx.author.voice.channel is None:
await ctx.send("❌ You need to be in a voice channel.") await ctx.send("You need to be in a voice channel.")
return False return False
target = ctx.author.voice.channel
me_voice = ctx.guild.me.voice me_voice = ctx.guild.me.voice
if me_voice is None or me_voice.channel != ctx.author.voice.channel: if me_voice is None or me_voice.channel is None:
await lavalink.connect(ctx.author.voice.channel) await lavalink.connect(target)
elif me_voice.channel.id != target.id:
# Already connected to a different channel: move instead of reconnecting.
try:
player = lavalink.get_player(ctx.guild.id)
await player.move_to(target)
except Exception:
await lavalink.connect(target)
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -271,23 +367,28 @@ class YTAudio(commands.Cog):
return return
gp = self._get_gp(ctx.guild.id) gp = self._get_gp(ctx.guild.id)
gp.volume = await self.config.guild(ctx.guild).volume() gp.volume = await self.config.guild(ctx.guild).volume()
track = QueuedTrack(query, ctx.author) track = QueuedTrack(query, ctx.author, ctx.channel)
await gp.queue.put(track) already_active = gp.current is not None or bool(gp.pending)
await ctx.send(f"➕ Queued: `{query}`") gp.enqueue(track)
if already_active:
position = len(gp.pending)
await ctx.send("Added to queue (position {}): {}".format(position, query))
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
async def skip(self, ctx: commands.Context): async def skip(self, ctx: commands.Context):
"""Skip the current track.""" """Skip the current track."""
gp = self.players.get(ctx.guild.id)
if gp is None or gp.current is None:
return await ctx.send("Nothing is playing.")
try: try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except Exception: except Exception:
return await ctx.send("❌ Nothing is playing.") return await ctx.send("Nothing is playing.")
# Stopping fires TRACK_END, which the event handler uses to advance
# the queue. Do not set next_event here or it would skip two tracks.
await player.stop() await player.stop()
gp = self.players.get(ctx.guild.id) await ctx.send("Skipped.")
if gp:
gp.next_event.set()
await ctx.send("⏭️ Skipped.")
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@@ -295,19 +396,14 @@ class YTAudio(commands.Cog):
"""Stop playback and clear the queue.""" """Stop playback and clear the queue."""
gp = self.players.get(ctx.guild.id) gp = self.players.get(ctx.guild.id)
if gp: if gp:
while not gp.queue.empty(): gp.clear_pending()
try:
t = gp.queue.get_nowait()
self._cleanup_file(t)
except asyncio.QueueEmpty:
break
gp.next_event.set() gp.next_event.set()
try: try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
await player.stop() await player.stop()
except Exception: except Exception:
pass pass
await ctx.send("⏹️ Stopped and cleared the queue.") await ctx.send("Stopped and cleared the queue.")
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@@ -316,9 +412,9 @@ class YTAudio(commands.Cog):
try: try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except Exception: except Exception:
return await ctx.send("❌ Nothing is playing.") return await ctx.send("Nothing is playing.")
await player.pause(True) await player.pause(True)
await ctx.send("⏸️ Paused.") await ctx.send("Paused.")
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@@ -327,24 +423,31 @@ class YTAudio(commands.Cog):
try: try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
except Exception: except Exception:
return await ctx.send("❌ Nothing is playing.") return await ctx.send("Nothing is playing.")
await player.pause(False) await player.pause(False)
await ctx.send("▶️ Resumed.") await ctx.send("Resumed.")
@commands.command(name="queue") @commands.command(name="queue", aliases=["q"])
@commands.guild_only() @commands.guild_only()
async def queue_cmd(self, ctx: commands.Context): async def queue_cmd(self, ctx: commands.Context):
"""Show the queue.""" """Show the current track and the queue."""
gp = self.players.get(ctx.guild.id) gp = self.players.get(ctx.guild.id)
if not gp or (gp.current is None and gp.queue.empty()): if not gp or (gp.current is None and not gp.pending):
return await ctx.send("The queue is empty.") return await ctx.send("The queue is empty.")
lines = [] lines = []
if gp.current: if gp.current:
lines.append(f"**Now:** {gp.current.title or gp.current.query}") lines.append("Now playing: {}".format(gp.current.title or gp.current.query))
pending: List[QueuedTrack] = list(gp.queue._queue) # snapshot if gp.pending:
for i, t in enumerate(pending, 1): lines.append("Up next:")
lines.append(f"{i}. {t.title or t.query}") for i, t in enumerate(gp.pending, 1):
await ctx.send("\n".join(lines[:20])) lines.append("{}. {}".format(i, t.title or t.query))
else:
lines.append("Queue is empty.")
# Keep within Discord message limits.
out = "\n".join(lines)
if len(out) > 1900:
out = out[:1900] + "\n..."
await ctx.send(out)
@commands.command(name="nowplaying", aliases=["np"]) @commands.command(name="nowplaying", aliases=["np"])
@commands.guild_only() @commands.guild_only()
@@ -353,7 +456,7 @@ class YTAudio(commands.Cog):
gp = self.players.get(ctx.guild.id) gp = self.players.get(ctx.guild.id)
if not gp or gp.current is None: if not gp or gp.current is None:
return await ctx.send("Nothing is playing.") return await ctx.send("Nothing is playing.")
await ctx.send(f"🎵 {gp.current.title or gp.current.query}") await ctx.send("Now playing: {}".format(gp.current.title or gp.current.query))
@commands.command() @commands.command()
@commands.guild_only() @commands.guild_only()
@@ -369,26 +472,22 @@ class YTAudio(commands.Cog):
await player.set_volume(level) await player.set_volume(level)
except Exception: except Exception:
pass pass
await ctx.send(f"🔊 Volume set to {level}.") await ctx.send("Volume set to {}.".format(level))
@commands.command() @commands.command(name="summon", aliases=["ytjoin"])
@commands.guild_only() @commands.guild_only()
async def join(self, ctx: commands.Context): async def join(self, ctx: commands.Context):
"""Join your voice channel.""" """Summon the bot to your voice channel."""
if await self._ensure_connected(ctx): if await self._ensure_connected(ctx):
await ctx.send("✅ Joined.") await ctx.send("Joined your voice channel.")
@commands.command(aliases=["disconnect", "dc"]) @commands.command(name="ytleave", aliases=["ytdc"])
@commands.guild_only() @commands.guild_only()
async def leave(self, ctx: commands.Context): async def leave(self, ctx: commands.Context):
"""Leave the voice channel and clear state.""" """Leave the voice channel and clear state."""
gp = self.players.get(ctx.guild.id) gp = self.players.get(ctx.guild.id)
if gp: if gp:
while not gp.queue.empty(): gp.clear_pending()
try:
self._cleanup_file(gp.queue.get_nowait())
except asyncio.QueueEmpty:
break
gp.next_event.set() gp.next_event.set()
try: try:
player = lavalink.get_player(ctx.guild.id) player = lavalink.get_player(ctx.guild.id)
@@ -396,7 +495,7 @@ class YTAudio(commands.Cog):
await player.disconnect() await player.disconnect()
except Exception: except Exception:
pass pass
await ctx.send("👋 Left the channel.") await ctx.send("Left the channel.")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Cleanup # Cleanup