Files
derp/plugins/music.py
user c5c61e63cc 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.
2026-02-21 23:32:16 +01:00

331 lines
8.6 KiB
Python

"""Plugin: music playback for Mumble voice channels."""
from __future__ import annotations
import asyncio
import logging
import subprocess
from dataclasses import dataclass
from derp.plugin import command
log = logging.getLogger(__name__)
_MAX_QUEUE = 50
_MAX_TITLE_LEN = 80
@dataclass(slots=True)
class _Track:
url: str
title: str
requester: str
# -- Per-bot runtime state ---------------------------------------------------
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("music", {
"queue": [],
"current": None,
"volume": 50,
"task": None,
"done_event": None,
})
# -- Helpers -----------------------------------------------------------------
def _is_mumble(bot) -> bool:
"""Check if bot supports voice streaming."""
return hasattr(bot, "stream_audio")
def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
"""Truncate text with ellipsis if needed."""
if len(text) <= max_len:
return text
return text[: max_len - 3].rstrip() + "..."
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", "--flat-playlist", "--print", "url",
"--print", "title", "--no-warnings",
f"--playlist-end={max_tracks}", url,
],
capture_output=True, text=True, timeout=30,
)
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, url)]
# -- Play loop ---------------------------------------------------------------
async def _play_loop(bot) -> None:
"""Pop tracks from queue and stream them sequentially."""
ps = _ps(bot)
try:
while ps["queue"]:
track = ps["queue"].pop(0)
ps["current"] = track
done = asyncio.Event()
ps["done_event"] = done
try:
await bot.stream_audio(
track.url,
volume=lambda: ps["volume"] / 100.0,
on_done=done,
)
except asyncio.CancelledError:
raise
except Exception:
log.exception("music: stream error for %s", track.url)
await done.wait()
except asyncio.CancelledError:
pass
finally:
ps["current"] = None
ps["done_event"] = None
ps["task"] = None
def _ensure_loop(bot) -> None:
"""Start the play loop if not already running."""
ps = _ps(bot)
task = ps.get("task")
if task and not task.done():
return
ps["task"] = bot._spawn(_play_loop(bot), name="music-play-loop")
# -- Commands ----------------------------------------------------------------
@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")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !play <url>")
return
url = parts[1].strip()
ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE:
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
return
remaining = _MAX_QUEUE - len(ps["queue"])
loop = asyncio.get_running_loop()
resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining)
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
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 {added} of {total_resolved} tracks (queue full)",
)
else:
await bot.reply(message, f"Queued {added} tracks")
if was_idle:
_ensure_loop(bot)
@command("stop", help="Music: !stop")
async def cmd_stop(bot, message):
"""Stop playback and clear queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
ps["queue"].clear()
task = ps.get("task")
if task and not task.done():
task.cancel()
ps["current"] = None
ps["task"] = None
ps["done_event"] = None
await bot.reply(message, "Stopped")
@command("skip", help="Music: !skip")
async def cmd_skip(bot, message):
"""Skip current track, advance to next in queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
task = ps.get("task")
if task and not task.done():
task.cancel()
skipped = ps["current"]
ps["current"] = None
ps["task"] = None
if ps["queue"]:
_ensure_loop(bot)
await bot.reply(
message,
f"Skipped: {_truncate(skipped.title)}",
)
else:
await bot.reply(message, "Skipped, queue empty")
@command("queue", help="Music: !queue [url]")
async def cmd_queue(bot, message):
"""Show queue or add a URL.
Usage:
!queue Show current queue
!queue <url> Add URL to queue (alias for !play)
"""
if not _is_mumble(bot):
return
parts = message.text.split(None, 1)
if len(parts) >= 2:
# Alias for !play
await cmd_play(bot, message)
return
ps = _ps(bot)
lines = []
if ps["current"]:
lines.append(
f"Now: {_truncate(ps['current'].title)}"
f" [{ps['current'].requester}]"
)
if ps["queue"]:
for i, track in enumerate(ps["queue"], 1):
lines.append(
f" {i}. {_truncate(track.title)} [{track.requester}]"
)
else:
if not ps["current"]:
lines.append("Queue empty")
for line in lines:
await bot.reply(message, line)
@command("np", help="Music: !np")
async def cmd_np(bot, message):
"""Show now-playing track."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
track = ps["current"]
await bot.reply(
message,
f"Now playing: {_truncate(track.title)} [{track.requester}]",
)
@command("testtone", help="Music: !testtone -- debug sine wave")
async def cmd_testtone(bot, message):
"""Send a 3-second test tone for voice debugging."""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
await bot.reply(message, "Sending 440Hz test tone (3s)...")
await bot.test_tone(3.0)
await bot.reply(message, "Test tone complete")
@command("volume", help="Music: !volume [0-100]")
async def cmd_volume(bot, message):
"""Get or set playback volume.
Usage:
!volume Show current volume
!volume <0-100> Set volume (takes effect immediately)
"""
if not _is_mumble(bot):
return
ps = _ps(bot)
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, f"Volume: {ps['volume']}%")
return
try:
val = int(parts[1])
except ValueError:
await bot.reply(message, "Usage: !volume <0-100>")
return
if val < 0 or val > 100:
await bot.reply(message, "Volume must be 0-100")
return
ps["volume"] = val
await bot.reply(message, f"Volume set to {val}%")