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.
This commit is contained in:
+94
-24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user