From 26c0b15513e6ea56e8950a1292ade8ebafc1c7f0 Mon Sep 17 00:00:00 2001 From: owen Date: Thu, 17 Sep 2026 17:11:59 -0500 Subject: [PATCH] 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. --- ytaudio/ytaudio.py | 118 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/ytaudio/ytaudio.py b/ytaudio/ytaudio.py index 8b82293..225a2fc 100644 --- a/ytaudio/ytaudio.py +++ b/ytaudio/ytaudio.py @@ -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: * On startup it calls ``lavalink.initialize()`` (once the bot is ready), @@ -22,6 +22,7 @@ import functools import logging import os import shutil +import time as _time from typing import Dict, List, Optional import discord @@ -62,13 +63,15 @@ YTDLP_BASE_OPTS = { class QueuedTrack: """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.requester = requester + self.channel = channel self.title: Optional[str] = None self.mp3_path: Optional[str] = None + self.status_msg = None class GuildPlayer: @@ -137,7 +140,7 @@ class YTAudio(commands.Cog): async def _connect_lavalink(self): await self.bot.wait_until_red_ready() # 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(): self._ll_ready = True log.info("YTAudio: a Lavalink node is already connected; sharing it.") @@ -195,10 +198,55 @@ class YTAudio(commands.Cog): gp.next_event.set() # ------------------------------------------------------------------ - # yt-dlp resolution (blocking -> executor) + # yt-dlp resolution (blocking -> executor) with live progress # ------------------------------------------------------------------ - def _blocking_extract(self, query: str) -> dict: - with yt_dlp.YoutubeDL(self._ytdlp_opts) as ydl: + def _make_progress_hook(self, loop, track: QueuedTrack): + # 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) if "entries" in info: # search/playlist -> first entry info = info["entries"][0] @@ -206,8 +254,9 @@ class YTAudio(commands.Cog): async def _resolve_to_mp3(self, track: QueuedTrack): loop = asyncio.get_running_loop() + hook = self._make_progress_hook(loop, track) 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) vid = info.get("id") @@ -226,6 +275,15 @@ class YTAudio(commands.Cog): # Playback # ------------------------------------------------------------------ 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) player = lavalink.get_player(guild_id) gp = self.players.get(guild_id) @@ -234,11 +292,21 @@ class YTAudio(commands.Cog): loaded = await player.load_tracks(track.mp3_path) 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) + if status_msg is not None: + try: + await status_msg.edit(content="Failed to load: {}".format(track.query)) + except Exception: + pass done_event.set() return player.add(requester=track.requester, track=loaded.tracks[0]) if not player.is_playing: 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: gp = self.players.get(guild_id) @@ -250,10 +318,10 @@ class YTAudio(commands.Cog): async def _ensure_connected(self, ctx: commands.Context) -> bool: 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 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 me_voice = ctx.guild.me.voice if me_voice is None or me_voice.channel != ctx.author.voice.channel: @@ -271,9 +339,11 @@ class YTAudio(commands.Cog): return gp = self._get_gp(ctx.guild.id) gp.volume = await self.config.guild(ctx.guild).volume() - track = QueuedTrack(query, ctx.author) + track = QueuedTrack(query, ctx.author, ctx.channel) + already_active = gp.current is not None or not gp.queue.empty() await gp.queue.put(track) - await ctx.send(f"➕ Queued: `{query}`") + if already_active: + await ctx.send("Added to queue: {}".format(query)) @commands.command() @commands.guild_only() @@ -282,12 +352,12 @@ class YTAudio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except Exception: - return await ctx.send("❌ Nothing is playing.") + return await ctx.send("Nothing is playing.") await player.stop() gp = self.players.get(ctx.guild.id) if gp: gp.next_event.set() - await ctx.send("⏭️ Skipped.") + await ctx.send("Skipped.") @commands.command() @commands.guild_only() @@ -307,7 +377,7 @@ class YTAudio(commands.Cog): await player.stop() except Exception: pass - await ctx.send("⏹️ Stopped and cleared the queue.") + await ctx.send("Stopped and cleared the queue.") @commands.command() @commands.guild_only() @@ -316,9 +386,9 @@ class YTAudio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except Exception: - return await ctx.send("❌ Nothing is playing.") + return await ctx.send("Nothing is playing.") await player.pause(True) - await ctx.send("⏸️ Paused.") + await ctx.send("Paused.") @commands.command() @commands.guild_only() @@ -327,9 +397,9 @@ class YTAudio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except Exception: - return await ctx.send("❌ Nothing is playing.") + return await ctx.send("Nothing is playing.") await player.pause(False) - await ctx.send("▶️ Resumed.") + await ctx.send("Resumed.") @commands.command(name="queue") @commands.guild_only() @@ -340,7 +410,7 @@ class YTAudio(commands.Cog): return await ctx.send("The queue is empty.") lines = [] 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 for i, t in enumerate(pending, 1): lines.append(f"{i}. {t.title or t.query}") @@ -353,7 +423,7 @@ class YTAudio(commands.Cog): gp = self.players.get(ctx.guild.id) if not gp or gp.current is None: 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.guild_only() @@ -369,14 +439,14 @@ class YTAudio(commands.Cog): await player.set_volume(level) except Exception: pass - await ctx.send(f"🔊 Volume set to {level}.") + await ctx.send("Volume set to {}.".format(level)) @commands.command(name="ytjoin") @commands.guild_only() async def join(self, ctx: commands.Context): """Join your voice channel.""" if await self._ensure_connected(ctx): - await ctx.send("✅ Joined.") + await ctx.send("Joined.") @commands.command(name="ytleave", aliases=["ytdc"]) @commands.guild_only() @@ -396,7 +466,7 @@ class YTAudio(commands.Cog): await player.disconnect() except Exception: pass - await ctx.send("👋 Left the channel.") + await ctx.send("Left the channel.") # ------------------------------------------------------------------ # Cleanup