"""Plugin: music playback for Mumble voice channels.""" from __future__ import annotations import asyncio import hashlib import json import logging import random import subprocess import time from dataclasses import dataclass from pathlib import Path 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 origin: str = "" # original user-provided URL for re-resolution local_path: Path | None = None # set before playback keep: bool = False # True = don't delete after playback # -- Per-bot runtime state --------------------------------------------------- def _ps(bot): """Per-bot plugin runtime state.""" cfg = getattr(bot, "config", {}).get("music", {}) return bot._pstate.setdefault("music", { "queue": [], "current": None, "volume": 50, "task": None, "done_event": None, "duck_enabled": cfg.get("duck_enabled", True), "duck_floor": cfg.get("duck_floor", 1), "duck_silence": cfg.get("duck_silence", 15), "duck_restore": cfg.get("duck_restore", 30), "duck_vol": None, "duck_task": None, "_watcher_task": 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}" def _parse_seek(arg: str) -> tuple[str, float]: """Parse a seek offset string into (mode, seconds). Returns ``("abs", seconds)`` for absolute seeks (``1:30``, ``90``) or ``("rel", +/-seconds)`` for relative (``+30``, ``-1:00``). Raises ``ValueError`` on invalid input. """ if not arg: raise ValueError("empty seek argument") mode = "abs" raw = arg if raw[0] in ("+", "-"): mode = "rel" sign = -1 if raw[0] == "-" else 1 raw = raw[1:] else: sign = 1 if ":" in raw: parts = raw.split(":", 1) try: minutes = int(parts[0]) seconds = int(parts[1]) except ValueError: raise ValueError(f"invalid seek format: {arg}") total = minutes * 60 + seconds else: try: total = int(raw) except ValueError: raise ValueError(f"invalid seek format: {arg}") return (mode, sign * float(total)) # -- 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.origin or 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)] # -- Download helpers -------------------------------------------------------- _MUSIC_DIR = Path("data/music") def _download_track(url: str, track_id: str) -> Path | None: """Download audio to data/music/. Blocking -- run in executor.""" _MUSIC_DIR.mkdir(parents=True, exist_ok=True) template = str(_MUSIC_DIR / f"{track_id}.%(ext)s") try: result = subprocess.run( ["yt-dlp", "-f", "bestaudio", "--no-warnings", "-o", template, "--print", "after_move:filepath", url], capture_output=True, text=True, timeout=300, ) filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "" if filepath and Path(filepath).is_file(): return Path(filepath) matches = list(_MUSIC_DIR.glob(f"{track_id}.*")) return matches[0] if matches else None except Exception: log.exception("download failed for %s", url) return None def _cleanup_track(track: _Track) -> None: """Delete the local audio file unless marked to keep.""" if track.local_path is None or track.keep: return try: track.local_path.unlink(missing_ok=True) log.info("music: deleted %s", track.local_path.name) except OSError: log.warning("music: failed to delete %s", track.local_path) # -- Duck monitor ------------------------------------------------------------ async def _duck_monitor(bot) -> None: """Background task: duck volume when voice is detected, restore on silence. Ducking is immediate (snap to floor). Restoration is a single smooth linear ramp from floor to user volume over ``duck_restore`` seconds. The per-frame volume ramp in ``stream_audio`` further smooths each 1-second update, eliminating audible steps. """ ps = _ps(bot) restore_start: float = 0.0 # monotonic ts when restore began restore_from: float = 0.0 # duck_vol at restore start try: while True: await asyncio.sleep(1) if not ps["duck_enabled"]: if ps["duck_vol"] is not None: ps["duck_vol"] = None restore_start = 0.0 continue ts = getattr(bot, "_last_voice_ts", 0.0) if ts == 0.0: continue silence = time.monotonic() - ts if silence < ps["duck_silence"]: # Voice active -- duck immediately if ps["duck_vol"] is None: log.info("duck: voice detected, ducking to %d%%", ps["duck_floor"]) ps["duck_vol"] = float(ps["duck_floor"]) restore_start = 0.0 elif ps["duck_vol"] is not None: # Silence exceeded -- smooth linear restore if restore_start == 0.0: restore_start = time.monotonic() restore_from = ps["duck_vol"] log.info("duck: restoring %d%% -> %d%% over %ds", int(restore_from), ps["volume"], ps["duck_restore"]) elapsed = time.monotonic() - restore_start dur = ps["duck_restore"] if dur <= 0 or elapsed >= dur: ps["duck_vol"] = None restore_start = 0.0 else: target = ps["volume"] ps["duck_vol"] = restore_from + (target - restore_from) * (elapsed / dur) except asyncio.CancelledError: ps["duck_vol"] = None # -- Auto-resume on reconnect ------------------------------------------------ async def _auto_resume(bot) -> None: """Wait for silence after reconnect, then resume saved playback.""" ps = _ps(bot) if ps["current"] is not None: return data = _load_resume(bot) if data is None: return elapsed = data.get("elapsed", 0.0) title = _truncate(data.get("title", data["url"])) pos = _fmt_time(elapsed) # Let pymumble fully stabilize after reconnect await asyncio.sleep(10) deadline = time.monotonic() + 60 silence_needed = ps.get("duck_silence", 15) ts = getattr(bot, "_last_voice_ts", 0.0) if ts != 0.0 and time.monotonic() - ts < silence_needed: await bot.send("0", f"Resuming '{title}' at {pos} once silent for " f"{silence_needed}s") else: await bot.send("0", f"Resuming '{title}' at {pos} in a moment") while time.monotonic() < deadline: await asyncio.sleep(2) ts = getattr(bot, "_last_voice_ts", 0.0) if ts == 0.0: break if time.monotonic() - ts >= silence_needed: break else: log.info("music: auto-resume aborted, channel not silent after 60s") await bot.send("0", f"Resume of '{title}' aborted -- " "channel not silent") return # Re-check after waiting -- someone may have started playback manually if ps["current"] is not None: return data = _load_resume(bot) if data is None: 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) log.info("music: auto-resuming '%s' from %s", track.title, _fmt_time(elapsed)) _ensure_loop(bot, seek=elapsed) async def _reconnect_watcher(bot) -> None: """Poll for reconnections and trigger auto-resume. Also handles cold-start resume: if saved state exists on first run, waits for the connection to stabilize then resumes. """ last_seen = getattr(bot, "_connect_count", 0) boot_checked = False while True: await asyncio.sleep(2) count = getattr(bot, "_connect_count", 0) # Cold-start: resume saved state after first connection if not boot_checked and count >= 1: boot_checked = True if _load_resume(bot) is not None: log.info("music: saved state found on boot, attempting auto-resume") await _auto_resume(bot) continue if count > last_seen and count > 1: last_seen = count log.info("music: reconnection detected, attempting auto-resume") await _auto_resume(bot) last_seen = max(last_seen, count) # -- Play loop --------------------------------------------------------------- async def _play_loop(bot, *, seek: float = 0.0) -> None: """Pop tracks from queue and stream them sequentially.""" ps = _ps(bot) duck_task = bot._spawn(_duck_monitor(bot), name="music-duck-monitor") ps["duck_task"] = duck_task 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] ps["progress"] = progress ps["cur_seek"] = cur_seek # Download phase source = track.url if track.local_path is None: loop = asyncio.get_running_loop() tid = hashlib.md5(track.url.encode()).hexdigest()[:12] dl_path = await loop.run_in_executor( None, _download_track, track.url, tid, ) if dl_path: track.local_path = dl_path source = str(dl_path) else: log.warning("music: download failed, streaming %s", track.url) else: source = str(track.local_path) try: await bot.stream_audio( source, volume=lambda: ( ps["duck_vol"] if ps["duck_vol"] is not None else 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) elapsed = cur_seek + progress[0] * 0.02 if elapsed > 1.0: _save_resume(bot, track, elapsed) break await done.wait() if progress[0] > 0: _clear_resume(bot) _cleanup_track(track) except asyncio.CancelledError: pass finally: if duck_task and not duck_task.done(): duck_task.cancel() ps["current"] = None ps["done_event"] = None ps["task"] = None ps["duck_vol"] = None ps["duck_task"] = None ps["progress"] = None ps["cur_seek"] = 0.0 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 # Only set origin for direct URLs (not searches) so resume uses the # resolved video URL rather than an ephemeral search query origin = url if not is_search else "" for track_url, track_title in resolved[:remaining]: ps["queue"].append(_Track(url=track_url, title=track_title, requester=requester, origin=origin)) 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() try: await task except (asyncio.CancelledError, Exception): pass else: ps["current"] = None ps["task"] = None ps["duck_vol"] = 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 skipped = ps["current"] task = ps.get("task") if task and not task.done(): task.cancel() try: await task except (asyncio.CancelledError, Exception): pass if ps["queue"]: _ensure_loop(bot) await bot.reply( message, f"Skipped: {_truncate(skipped.title)}", ) else: await bot.reply(message, "Skipped, queue empty") @command("seek", help="Music: !seek ") async def cmd_seek(bot, message): """Seek to position in current track. Usage: !seek 1:30 Seek to 1 minute 30 seconds !seek 90 Seek to 90 seconds !seek +30 Jump forward 30 seconds !seek -30 Jump backward 30 seconds !seek +1:00 Jump forward 1 minute """ if not _is_mumble(bot): return ps = _ps(bot) parts = message.text.split(None, 1) if len(parts) < 2: await bot.reply(message, "Usage: !seek (e.g. 1:30, +30, -30)") return try: mode, seconds = _parse_seek(parts[1].strip()) except ValueError: await bot.reply(message, "Usage: !seek (e.g. 1:30, +30, -30)") return track = ps["current"] if track is None: await bot.reply(message, "Nothing playing") return # Compute target position if mode == "abs": target = seconds else: progress = ps.get("progress") cur_seek = ps.get("cur_seek", 0.0) elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0) target = elapsed + seconds target = max(0.0, target) # Re-insert current track at front of queue (local_path intact) ps["queue"].insert(0, track) # Cancel the play loop and wait for cleanup task = ps.get("task") if task and not task.done(): task.cancel() try: await task except (asyncio.CancelledError, Exception): pass _ensure_loop(bot, seek=target) await bot.reply(message, f"Seeking to {_fmt_time(target)}") @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 bot.state.set("music", "volume", str(val)) await bot.reply(message, f"Volume set to {val}%") @command("duck", help="Music: !duck [on|off|floor N|silence N|restore N]") async def cmd_duck(bot, message): """Configure voice-activated volume ducking. Usage: !duck Show ducking status and settings !duck on Enable voice ducking !duck off Disable voice ducking !duck floor <0-100> Set floor volume % !duck silence Set silence timeout (seconds) !duck restore Set restore ramp duration (seconds) """ if not _is_mumble(bot): await bot.reply(message, "Mumble-only feature") return ps = _ps(bot) parts = message.text.split() if len(parts) < 2: state = "on" if ps["duck_enabled"] else "off" ducking = "" if ps["duck_vol"] is not None: ducking = f", ducked to {int(ps['duck_vol'])}%" await bot.reply( message, f"Duck: {state} | floor={ps['duck_floor']}%" f" silence={ps['duck_silence']}s" f" restore={ps['duck_restore']}s{ducking}", ) return sub = parts[1].lower() if sub == "on": ps["duck_enabled"] = True await bot.reply(message, "Voice ducking enabled") elif sub == "off": ps["duck_enabled"] = False ps["duck_vol"] = None await bot.reply(message, "Voice ducking disabled") elif sub == "floor": if len(parts) < 3: await bot.reply(message, "Usage: !duck floor <0-100>") return try: val = int(parts[2]) except ValueError: await bot.reply(message, "Usage: !duck floor <0-100>") return if val < 0 or val > 100: await bot.reply(message, "Floor must be 0-100") return ps["duck_floor"] = val await bot.reply(message, f"Duck floor set to {val}%") elif sub == "silence": if len(parts) < 3: await bot.reply(message, "Usage: !duck silence ") return try: val = int(parts[2]) except ValueError: await bot.reply(message, "Usage: !duck silence ") return if val < 1: await bot.reply(message, "Silence timeout must be >= 1") return ps["duck_silence"] = val await bot.reply(message, f"Duck silence set to {val}s") elif sub == "restore": if len(parts) < 3: await bot.reply(message, "Usage: !duck restore ") return try: val = int(parts[2]) except ValueError: await bot.reply(message, "Usage: !duck restore ") return if val < 1: await bot.reply(message, "Restore duration must be >= 1") return ps["duck_restore"] = val await bot.reply(message, f"Duck restore set to {val}s") else: await bot.reply( message, "Usage: !duck [on|off|floor N|silence N|restore N]", ) @command("keep", help="Music: !keep -- keep current track's audio file") async def cmd_keep(bot, message): """Mark the current track's local file to keep after playback.""" if not _is_mumble(bot): await bot.reply(message, "Mumble-only feature") return ps = _ps(bot) track = ps["current"] if track is None: await bot.reply(message, "Nothing playing") return if track.local_path is None: await bot.reply(message, "No local file for current track") return track.keep = True await bot.reply(message, f"Keeping: {track.local_path.name}") @command("kept", help="Music: !kept [clear] -- list or clear kept files") async def cmd_kept(bot, message): """List or clear kept audio files in data/music/.""" if not _is_mumble(bot): await bot.reply(message, "Mumble-only feature") return parts = message.text.split() if len(parts) >= 2 and parts[1].lower() == "clear": count = 0 if _MUSIC_DIR.is_dir(): for f in _MUSIC_DIR.iterdir(): if f.is_file(): f.unlink() count += 1 await bot.reply(message, f"Deleted {count} file(s)") return files = sorted(_MUSIC_DIR.iterdir()) if _MUSIC_DIR.is_dir() else [] files = [f for f in files if f.is_file()] if not files: await bot.reply(message, "No kept files") return lines = [f"Kept files ({len(files)}):"] for f in files: size_mb = f.stat().st_size / (1024 * 1024) lines.append(f" {f.name} ({size_mb:.1f}MB)") for line in lines: await bot.reply(message, line) # -- Plugin lifecycle -------------------------------------------------------- async def on_connected(bot) -> None: """Called by MumbleBot after each (re)connection. Ensures the reconnect watcher is running -- triggers boot-resume and reconnect-resume without waiting for a user command. """ if not _is_mumble(bot): return ps = _ps(bot) saved_vol = bot.state.get("music", "volume") if saved_vol is not None: try: ps["volume"] = max(0, min(100, int(saved_vol))) except ValueError: pass if ps["_watcher_task"] is None and hasattr(bot, "_spawn"): ps["_watcher_task"] = bot._spawn( _reconnect_watcher(bot), name="music-reconnect-watcher", )