"""Plugin: music playback for Mumble voice channels.""" from __future__ import annotations import asyncio import json import logging import random 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 _is_url(text: str) -> bool: """Check if text looks like a URL rather than a search query.""" return text.startswith(("http://", "https://", "ytsearch:")) def _fmt_time(seconds: float) -> str: """Format seconds as M:SS.""" m, s = divmod(int(seconds), 60) return f"{m}:{s:02d}" # -- Resume state persistence ------------------------------------------------ def _save_resume(bot, track: _Track, elapsed: float) -> None: """Persist current track and elapsed position for later resumption.""" data = json.dumps({ "url": track.url, "title": track.title, "requester": track.requester, "elapsed": round(elapsed, 2), }) bot.state.set("music", "resume", data) def _load_resume(bot) -> dict | None: """Load resume data, or None if absent/corrupt.""" raw = bot.state.get("music", "resume") if not raw: return None try: data = json.loads(raw) if not isinstance(data, dict) or "url" not in data: return None return data except (json.JSONDecodeError, TypeError): return None def _clear_resume(bot) -> None: """Remove persisted resume state.""" bot.state.delete("music", "resume") 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() # --flat-playlist prints "NA" for single videos (no extraction) if not track_url or track_url == "NA": track_url = 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, *, seek: float = 0.0) -> None: """Pop tracks from queue and stream them sequentially.""" ps = _ps(bot) first = True try: while ps["queue"]: track = ps["queue"].pop(0) ps["current"] = track done = asyncio.Event() ps["done_event"] = done cur_seek = seek if first else 0.0 first = False progress = [0] try: await bot.stream_audio( track.url, volume=lambda: ps["volume"] / 100.0, on_done=done, seek=cur_seek, progress=progress, ) except asyncio.CancelledError: elapsed = cur_seek + progress[0] * 0.02 if elapsed > 1.0: _save_resume(bot, track, elapsed) raise except Exception: log.exception("music: stream error for %s", track.url) _clear_resume(bot) await done.wait() except asyncio.CancelledError: pass finally: ps["current"] = None ps["done_event"] = None ps["task"] = None def _ensure_loop(bot, *, seek: float = 0.0) -> 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, seek=seek), 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.) !play Search YouTube and play the first result 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() is_search = not _is_url(url) if is_search: url = f"ytsearch10:{url}" 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) # Search: pick one random result instead of enqueuing all if is_search and len(resolved) > 1: resolved = [random.choice(resolved)] 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("resume", help="Music: !resume -- resume last stopped track") async def cmd_resume(bot, message): """Resume playback from the last interrupted position. Loads the track URL and elapsed time saved when playback was stopped or skipped. The position persists across bot restarts. """ if not _is_mumble(bot): await bot.reply(message, "Music playback is Mumble-only") return ps = _ps(bot) if ps["current"] is not None: await bot.reply(message, "Already playing") return data = _load_resume(bot) if data is None: await bot.reply(message, "Nothing to resume") return elapsed = data.get("elapsed", 0.0) track = _Track( url=data["url"], title=data.get("title", data["url"]), requester=data.get("requester", "?"), ) ps["queue"].insert(0, track) _clear_resume(bot) await bot.reply( message, f"Resuming: {_truncate(track.title)} from {_fmt_time(elapsed)}", ) _ensure_loop(bot, seek=elapsed) @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|+N|-N]") async def cmd_volume(bot, message): """Get or set playback volume. Usage: !volume Show current volume !volume <0-100> Set volume (takes effect immediately) !volume +N/-N Adjust volume relatively """ 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 arg = parts[1].strip() relative = arg.startswith("+") or (arg.startswith("-") and arg != "-") try: val = int(arg) except ValueError: await bot.reply(message, "Usage: !volume <0-100|+N|-N>") return if relative: val = ps["volume"] + val 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}%")