feat: expand YouTube playlists into individual queue tracks

_resolve_title replaced with _resolve_tracks using --flat-playlist to
enumerate playlist entries. cmd_play enqueues each track individually,
with truncation when the queue is nearly full. Single-video behavior
unchanged.
This commit is contained in:
user
2026-02-21 23:32:16 +01:00
parent 67b2dc827d
commit c5c61e63cc
2 changed files with 161 additions and 20 deletions

View File

@@ -51,17 +51,33 @@ def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
return text[: max_len - 3].rstrip() + "..."
def _resolve_title(url: str) -> str:
"""Resolve track title via yt-dlp. Blocking, run in executor."""
def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, str]]:
"""Resolve URL into (url, title) pairs via yt-dlp. Blocking, run in executor.
Handles both single videos and playlists. For playlists, returns up to
``max_tracks`` individual entries. Falls back to ``[(url, url)]`` on error.
"""
try:
result = subprocess.run(
["yt-dlp", "--get-title", "--no-warnings", url],
capture_output=True, text=True, timeout=15,
[
"yt-dlp", "--flat-playlist", "--print", "url",
"--print", "title", "--no-warnings",
f"--playlist-end={max_tracks}", url,
],
capture_output=True, text=True, timeout=30,
)
title = result.stdout.strip()
return title if title else url
lines = result.stdout.strip().splitlines()
if len(lines) < 2:
return [(url, url)]
tracks = []
for i in range(0, len(lines) - 1, 2):
track_url = lines[i].strip()
track_title = lines[i + 1].strip()
if track_url:
tracks.append((track_url, track_title or track_url))
return tracks if tracks else [(url, url)]
except Exception:
return url
return [(url, url)]
# -- Play loop ---------------------------------------------------------------
@@ -110,12 +126,15 @@ def _ensure_loop(bot) -> None:
# -- Commands ----------------------------------------------------------------
@command("play", help="Music: !play <url>")
@command("play", help="Music: !play <url|playlist>")
async def cmd_play(bot, message):
"""Play a URL or add to queue if already playing.
Usage:
!play <url> Play audio from URL (YouTube, SoundCloud, etc.)
Playlists are expanded into individual tracks. If the queue is nearly
full, only as many tracks as will fit are enqueued.
"""
if not _is_mumble(bot):
await bot.reply(message, "Music playback is Mumble-only")
@@ -133,20 +152,36 @@ async def cmd_play(bot, message):
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
return
remaining = _MAX_QUEUE - len(ps["queue"])
loop = asyncio.get_running_loop()
title = await loop.run_in_executor(None, _resolve_title, url)
track = _Track(url=url, title=title, requester=message.nick or "?")
resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining)
ps["queue"].append(track)
was_idle = ps["current"] is None
requester = message.nick or "?"
added = 0
for track_url, track_title in resolved[:remaining]:
ps["queue"].append(_Track(url=track_url, title=track_title,
requester=requester))
added += 1
if ps["current"] is not None:
pos = len(ps["queue"])
total_resolved = len(resolved)
if added == 1:
title = _truncate(resolved[0][1])
if was_idle:
await bot.reply(message, f"Playing: {title}")
else:
pos = len(ps["queue"])
await bot.reply(message, f"Queued #{pos}: {title}")
elif added < total_resolved:
await bot.reply(
message,
f"Queued #{pos}: {_truncate(title)}",
f"Queued {added} of {total_resolved} tracks (queue full)",
)
else:
await bot.reply(message, f"Playing: {_truncate(title)}")
await bot.reply(message, f"Queued {added} tracks")
if was_idle:
_ensure_loop(bot)