"""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_title(url: str) -> str: """Resolve track title via yt-dlp. Blocking, run in executor.""" try: result = subprocess.run( ["yt-dlp", "--get-title", "--no-warnings", url], capture_output=True, text=True, timeout=15, ) title = result.stdout.strip() return title if title else url except Exception: return 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 volume = ps["volume"] / 100.0 try: await bot.stream_audio( track.url, volume=volume, 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 ") async def cmd_play(bot, message): """Play a URL or add to queue if already playing. Usage: !play Play audio from URL (YouTube, SoundCloud, etc.) """ 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 ") 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 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 "?") ps["queue"].append(track) if ps["current"] is not None: pos = len(ps["queue"]) await bot.reply( message, f"Queued #{pos}: {_truncate(title)}", ) else: await bot.reply(message, f"Playing: {_truncate(title)}") _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 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("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 (applies on next track) """ 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}%")