"""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 ") 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.) 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 ") 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 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}%")