"""Plugin: music playback for Mumble voice channels.""" from __future__ import annotations import asyncio import hashlib import json import logging import random import re import shutil import subprocess import time from dataclasses import dataclass from pathlib import Path from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from derp.plugin import command log = logging.getLogger(__name__) _MAX_QUEUE = 50 _MAX_TITLE_LEN = 80 _PLAYLIST_BATCH = 10 # initial tracks resolved before playback starts @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 duration: float = 0.0 # total duration in seconds (0 = unknown) # -- 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", 2), "duck_silence": cfg.get("duck_silence", 15), "duck_restore": cfg.get("duck_restore", 30), "duck_vol": None, "duck_task": None, "fade_vol": None, "fade_step": None, "history": [], "autoplay": cfg.get("autoplay", True), "autoplay_cooldown": cfg.get("autoplay_cooldown", 30), "discover": cfg.get("discover", True), "discover_ratio": cfg.get("discover_ratio", 3), "announce": cfg.get("announce", False), "paused": 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() + "..." _YT_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") def _expand_video_id(text: str) -> str: """Expand a bare YouTube video ID to a full URL.""" if _YT_VIDEO_ID_RE.match(text): return f"https://www.youtube.com/watch?v={text}" return text 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 _strip_playlist_params(url: str) -> str: """Strip playlist context params from a YouTube URL. Keeps only the video identifier so resume/download targets the exact video instead of resolving through a radio mix or playlist. """ parsed = urlparse(url) if "youtube.com" not in parsed.netloc and "youtu.be" not in parsed.netloc: return url params = parse_qs(parsed.query, keep_blank_values=True) # Keep only the video ID; drop list, index, start_radio, pp, etc. clean = {k: v for k, v in params.items() if k == "v"} if not clean: return url return urlunparse(parsed._replace(query=urlencode(clean, doseq=True))) def _save_resume(bot, track: _Track, elapsed: float) -> None: """Persist current track and elapsed position for later resumption.""" data = json.dumps({ "url": _strip_playlist_params(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, start: int = 1) -> 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 starting from 1-based index ``start``. Falls back to ``[(url, url)]`` on error. YouTube URLs with ``&list=`` are passed through intact so yt-dlp can resolve the full playlist. Playlist params are only stripped in ``_save_resume()`` where we need the exact video for resume. """ end = start + max_tracks - 1 try: result = subprocess.run( [ "yt-dlp", "--flat-playlist", "--print", "url", "--print", "title", "--no-warnings", f"--playlist-start={start}", f"--playlist-end={end}", url, ], capture_output=True, text=True, timeout=30, ) lines = result.stdout.strip().splitlines() if len(lines) < 2: if start > 1: return [] # no more pages 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 ([] if start > 1 else [(url, url)]) except Exception: return [] if start > 1 else [(url, url)] def _probe_duration(path: str) -> float: """Get duration in seconds via ffprobe. Blocking -- run in executor.""" try: result = subprocess.run( ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path], capture_output=True, text=True, timeout=5, ) return float(result.stdout.strip()) except Exception: return 0.0 # -- Download helpers -------------------------------------------------------- _MUSIC_DIR = Path("data/music") # kept tracks (persistent) _CACHE_DIR = Path("data/music/cache") # temporary playback downloads def _fetch_metadata(url: str) -> dict: """Fetch track metadata via yt-dlp. Blocking -- run in executor.""" try: result = subprocess.run( ["yt-dlp", "--print", "title", "--print", "artist", "--print", "duration", "--no-warnings", "--no-download", "--no-playlist", url], capture_output=True, text=True, timeout=15, ) lines = result.stdout.strip().splitlines() return { "title": lines[0] if len(lines) > 0 else "", "artist": lines[1] if len(lines) > 1 else "", "duration": (float(lines[2]) if len(lines) > 2 and lines[2].replace(".", "", 1).isdigit() else 0), } except Exception: log.warning("music: metadata fetch failed for %s", url) return {"title": "", "artist": "", "duration": 0} def _sanitize_filename(title: str, fallback: str) -> str: """Convert a track title to a clean, filesystem-safe filename. Keeps alphanumeric chars, hyphens, and underscores. Collapses whitespace/separators into single hyphens. Falls back to the URL-based hash if the title produces nothing usable. """ name = re.sub(r"[^\w\s-]", "", title.lower()) name = re.sub(r"[\s_-]+", "-", name).strip("-") if not name: return fallback return name[:80] def _download_track(url: str, track_id: str, title: str = "") -> Path | None: """Download audio to cache dir. Blocking -- run in executor. Checks the kept directory first (reuse kept files). New downloads go to the cache dir and are cleaned up after playback unless kept. """ filename = _sanitize_filename(title, track_id) if title else track_id _MIN_CACHE_SIZE = 100 * 1024 # 100 KB -- skip partial downloads # Reuse existing kept or cached file for d in (_MUSIC_DIR, _CACHE_DIR): for name in (filename, track_id): existing = list(d.glob(f"{name}.*")) if d.is_dir() else [] for f in existing: # Trust kept files; skip suspiciously small cache files if d == _MUSIC_DIR or f.stat().st_size >= _MIN_CACHE_SIZE: return f _CACHE_DIR.mkdir(parents=True, exist_ok=True) template = str(_CACHE_DIR / f"{track_id}.%(ext)s") try: result = subprocess.run( ["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites", "--no-playlist", "--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(_CACHE_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 or in kept dir.""" if track.local_path is None or track.keep: return # Never delete files from the kept directory -- they may have been # reused by _download_track for a non-kept playback of the same URL. if track.local_path.parent == _MUSIC_DIR: 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 ------------------------------------------------------------ def _all_users_muted(bot) -> bool: """True when every non-bot user in the channel is muted or deafened. Used to skip the duck silence threshold -- if everyone has muted, there's no conversation to protect and music can restore immediately. """ if not hasattr(bot, "_mumble") or bot._mumble is None: return False bots = getattr(bot.registry, "_bots", {}) try: found_human = False for session_id in list(bot._mumble.users): user = bot._mumble.users[session_id] name = user["name"] if name in bots: continue found_human = True if not (user["self_mute"] or user["mute"] or user["self_deaf"]): return False return found_human except Exception: return False 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.registry, "_voice_ts", 0.0) tts = getattr(bot.registry, "_tts_active", False) if ts == 0.0 and not tts and ps["duck_vol"] is None: continue silence = time.monotonic() - ts if ts else float("inf") should_duck = silence < ps["duck_silence"] or tts # Override: all users muted -- no conversation to protect if should_duck and not tts and _all_users_muted(bot): should_duck = False if should_duck: # 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.registry, "_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.registry, "_voice_ts", 0.0) if ts == 0.0: break if time.monotonic() - ts >= silence_needed: break if _all_users_muted(bot): 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) def _load_kept_tracks(bot) -> list[_Track]: """Load all kept tracks from state with valid local files.""" tracks = [] for key in bot.state.keys("music"): if not key.startswith("keep:"): continue raw = bot.state.get("music", key) if not raw: continue try: meta = json.loads(raw) except (json.JSONDecodeError, TypeError): continue filename = meta.get("filename", "") if not filename: continue fpath = _MUSIC_DIR / filename if not fpath.is_file(): continue tracks.append(_Track( url=meta.get("url", str(fpath)), title=meta.get("title") or filename, requester="autoplay", local_path=fpath, keep=True, duration=float(meta.get("duration", 0)), )) return tracks async def _autoplay_kept(bot) -> None: """Start autoplay loop -- the play loop handles silence-wait + random pick.""" ps = _ps(bot) if ps["current"] is not None: return kept = _load_kept_tracks(bot) if not kept: return # Let pymumble fully stabilize await asyncio.sleep(10) if ps["current"] is not None: return log.info("music: autoplay starting (%d kept tracks available)", len(kept)) _ensure_loop(bot) 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 or autoplay 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) elif _ps(bot).get("autoplay", True): await _autoplay_kept(bot) continue if count > last_seen and count > 1: last_seen = count if _load_resume(bot) is not None: log.info("music: reconnection detected, attempting auto-resume") await _auto_resume(bot) elif _ps(bot).get("autoplay", True): await _autoplay_kept(bot) last_seen = max(last_seen, count) # -- Play loop --------------------------------------------------------------- async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> 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 seek_req = [None] ps["seek_req"] = seek_req _autoplay_pool: list[_Track] = [] # shuffled deck, refilled each cycle _discover_seen: set[str] = set() # "artist:title" dedup within session _autoplay_count: int = 0 # autoplay picks since loop start try: while ps["queue"] or ps.get("autoplay"): # Autoplay: cooldown + silence wait, then pick next from shuffled deck if not ps["queue"]: _autoplay_count += 1 # -- Discovery attempt on every Nth autoplay pick -- discovered = False ratio = ps.get("discover_ratio", 3) if (ps.get("discover") and ratio > 0 and _autoplay_count % ratio == 0 and ps["history"]): last = ps["history"][-1] try: lfm = bot.registry._modules.get("lastfm") if lfm and hasattr(lfm, "discover_similar"): pair = await lfm.discover_similar(bot, last.title) if pair: a, t = pair key = f"{a.lower()}:{t.lower()}" if key not in _discover_seen: _discover_seen.add(key) loop = asyncio.get_running_loop() res = await loop.run_in_executor( None, _resolve_tracks, f"{a} {t}", 1, ) if res: discovered = True pick = _Track( url=res[0][0], title=res[0][1], requester="discover", ) log.info( "music: discovered '%s' " "similar to '%s'", pick.title, last.title, ) except Exception: log.warning( "music: discovery failed, using kept deck", exc_info=True, ) # -- Kept-deck fallback -- if not discovered: if not _autoplay_pool: kept = _load_kept_tracks(bot) if not kept: break random.shuffle(kept) _autoplay_pool = kept log.info("music: autoplay shuffled %d kept tracks", len(kept)) pick = _autoplay_pool.pop(0) cooldown = ps.get("autoplay_cooldown", 30) log.info("music: autoplay cooldown %ds before next track", cooldown) await asyncio.sleep(cooldown) # After cooldown, also wait for voice silence silence_needed = ps.get("duck_silence", 15) while True: await asyncio.sleep(2) ts = getattr(bot.registry, "_voice_ts", 0.0) if ts == 0.0 or time.monotonic() - ts >= silence_needed: break if _all_users_muted(bot): break # Re-check: someone may have queued something or stopped if ps["queue"]: continue ps["queue"].append(pick) log.info("music: autoplay queued '%s' (%d pool remaining)", pick.title, len(_autoplay_pool)) track = ps["queue"].pop(0) ps["current"] = track ps["fade_vol"] = None ps["fade_step"] = None seek_req[0] = None # clear stale seek from previous track done = asyncio.Event() ps["done_event"] = done cur_seek = seek if first else 0.0 if not first: fade_in = True # always fade in after first track 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, track.title, ) 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) # Probe duration if unknown if track.duration <= 0 and track.local_path: loop = asyncio.get_running_loop() track.duration = await loop.run_in_executor( None, _probe_duration, str(track.local_path), ) # Announce track if ps.get("announce"): dur = f" ({_fmt_time(track.duration)})" if track.duration > 0 else "" await bot.send("0", f"Playing: {_truncate(track.title)}{dur}") # Periodic resume-state saver (survives hard kills) async def _periodic_save(): try: while True: await asyncio.sleep(10) el = cur_seek + progress[0] * 0.02 if el > 1.0: _save_resume(bot, track, el) except asyncio.CancelledError: pass save_task = bot._spawn(_periodic_save(), name="music-save") try: await bot.stream_audio( source, volume=lambda: ( ps["fade_vol"] if ps["fade_vol"] is not None else ps["duck_vol"] if ps["duck_vol"] is not None else ps["volume"] ) / 100.0, on_done=done, seek=cur_seek, progress=progress, fade_step=lambda: ps.get("fade_step"), fade_in=fade_in, seek_req=seek_req, ) 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 finally: save_task.cancel() await done.wait() if progress[0] > 0: _clear_resume(bot) # Push finished track to history ps["history"].append(_Track(url=track.url, title=track.title, requester=track.requester, origin=track.origin)) if len(ps["history"]) > _MAX_HISTORY: ps["history"].pop(0) _cleanup_track(track) except asyncio.CancelledError: pass finally: # Clean up current track's cached file (skipped/stopped tracks) # but not when pausing -- the track is preserved for unpause current = ps.get("current") if current and ps.get("paused") is None: _cleanup_track(current) 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["fade_vol"] = None ps["fade_step"] = None ps["progress"] = None ps["cur_seek"] = 0.0 ps["seek_req"] = None def _ensure_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> 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, fade_in=fade_in), name="music-play-loop", ) _MAX_HISTORY = 10 async def _fade_and_cancel(bot, duration: float = 3.0) -> None: """Fade audio to zero over ``duration`` seconds, then cancel the task.""" ps = _ps(bot) task = ps.get("task") if not task or task.done(): return # Compute step from actual current volume so fade always spans `duration`. # At 3% vol: step = 0.03/40 = 0.00075 (still ~0.8s fade). # At 50% vol: step = 0.50/40 = 0.0125. cur_vol = ( ps["duck_vol"] if ps["duck_vol"] is not None else ps["volume"] ) / 100.0 n_frames = max(duration / 0.02, 1) step = max(cur_vol / n_frames, 0.0001) ps["fade_step"] = step ps["fade_vol"] = 0 log.debug("music: fading out (vol=%.2f, step=%.5f, duration=%.1fs)", cur_vol, step, duration) await asyncio.sleep(duration) # Hold at zero briefly so the ramp fully settles and pymumble # drains its output buffer -- prevents audible click on cancel. await asyncio.sleep(0.15) ps["fade_step"] = None if not task.done(): task.cancel() try: await task except (asyncio.CancelledError, Exception): pass # -- Lazy playlist resolution ------------------------------------------------ async def _playlist_feeder(bot, url: str, start: int, cap: int, shuffle: bool, requester: str, origin: str) -> None: """Background: resolve remaining playlist tracks and append to queue.""" ps = _ps(bot) loop = asyncio.get_running_loop() try: remaining = await loop.run_in_executor( None, _resolve_tracks, url, cap, start, ) if not remaining: return if shuffle: random.shuffle(remaining) added = 0 for track_url, title in remaining: if len(ps["queue"]) >= _MAX_QUEUE: break ps["queue"].append(_Track(url=track_url, title=title, requester=requester, origin=origin)) added += 1 tag = " (shuffled)" if shuffle else "" log.info("music: background-resolved %d more tracks%s", added, tag) except Exception: log.warning("music: background playlist resolution failed") # -- 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() ps = _ps(bot) # Play a kept track by ID: !play #3 if url.startswith("#"): kid = url[1:] raw = bot.state.get("music", f"keep:{kid}") if not raw: await bot.reply(message, f"No kept track with ID #{kid}") return try: meta = json.loads(raw) except (json.JSONDecodeError, TypeError): await bot.reply(message, f"Bad metadata for #{kid}") return fpath = _MUSIC_DIR / meta.get("filename", "") if not fpath.is_file(): await bot.reply(message, f"File missing for #{kid}") return title = meta.get("title") or meta.get("filename", kid) track = _Track(url=meta.get("url", str(fpath)), title=title, requester=message.nick or "?") track.local_path = fpath track.keep = True was_idle = ps["current"] is None ps["queue"].append(track) if was_idle: await bot.reply(message, f"Playing: {_truncate(title)}") else: await bot.reply(message, f"Queued #{len(ps['queue'])}: {_truncate(title)}") _ensure_loop(bot) return # Expand bare YouTube video IDs (e.g. "U1yQMjFZ6j4") url = _expand_video_id(url) # Strip #random fragment before URL classification / resolution shuffle = False if _is_url(url) and url.endswith("#random"): shuffle = True url = url[:-7] # strip "#random" is_search = not _is_url(url) if is_search: url = f"ytsearch10:{url}" if len(ps["queue"]) >= _MAX_QUEUE: await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)") return remaining = _MAX_QUEUE - len(ps["queue"]) is_playlist = not is_search and ("list=" in url or "playlist" in url) batch = min(_PLAYLIST_BATCH, remaining) if is_playlist else remaining if shuffle: await bot.reply(message, "Resolving playlist...") loop = asyncio.get_running_loop() resolved = await loop.run_in_executor(None, _resolve_tracks, url, batch) # Search: pick one random result instead of enqueuing all if is_search and len(resolved) > 1: resolved = [random.choice(resolved)] if shuffle and len(resolved) > 1: random.shuffle(resolved) was_idle = ps["current"] is None requester = message.nick or "?" # 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 "" added = 0 for track_url, track_title in resolved[:remaining]: ps["queue"].append(_Track(url=track_url, title=track_title, requester=requester, origin=origin)) added += 1 # Background-resolve remaining playlist tracks has_more = is_playlist and len(resolved) >= batch and added < remaining if has_more and hasattr(bot, "_spawn"): bot._spawn( _playlist_feeder(bot, url, batch + 1, remaining - added, shuffle, requester, origin), name="music-playlist-feeder", ) shuffled = " (shuffled)" if shuffle and added > 1 else "" 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 has_more: await bot.reply( message, f"Queued {added} tracks{shuffled}, resolving more...", ) elif added < len(resolved): await bot.reply( message, f"Queued {added} of {len(resolved)} tracks{shuffled} (queue full)", ) else: await bot.reply(message, f"Queued {added} tracks{shuffled}") 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() ps["paused"] = None task = ps.get("task") if task and not task.done(): await _fade_and_cancel(bot) else: ps["current"] = None ps["task"] = None ps["done_event"] = None ps["duck_vol"] = None ps["duck_task"] = None ps["fade_vol"] = None ps["fade_step"] = None ps["progress"] = None ps["cur_seek"] = 0.0 await bot.reply(message, "Stopped") _PAUSE_STALE = 45 # seconds before cached stream URLs are considered expired _PAUSE_REWIND = 3 # seconds to rewind on unpause for continuity @command("pause", help="Music: !pause -- toggle pause/unpause") async def cmd_pause(bot, message): """Pause or unpause playback. Pausing saves the current position and stops streaming. Unpausing resumes from where it left off. If paused longer than 45 seconds, non-local tracks are re-downloaded (stream URLs expire). """ if not _is_mumble(bot): return ps = _ps(bot) # -- Unpause --------------------------------------------------------- if ps["paused"] is not None: data = ps["paused"] ps["paused"] = None track = data["track"] elapsed = data["elapsed"] pause_dur = time.monotonic() - data["paused_at"] # Stale stream: discard cached file so play loop re-downloads if pause_dur > _PAUSE_STALE and track.local_path is not None: cache = _CACHE_DIR / track.local_path.name if track.local_path == cache or ( track.local_path.parent == _CACHE_DIR ): track.local_path.unlink(missing_ok=True) track.local_path = None log.info("music: pause stale (%.0fs), will re-download", pause_dur) # Rewind only if paused long enough to warrant it (anti-flood) rewind = _PAUSE_REWIND if pause_dur >= _PAUSE_REWIND else 0.0 seek_pos = max(0.0, elapsed - rewind) ps["queue"].insert(0, track) await bot.reply( message, f"Unpaused: {_truncate(track.title)} at {_fmt_time(seek_pos)}", ) _ensure_loop(bot, seek=seek_pos, fade_in=True) return # -- Pause ----------------------------------------------------------- if ps["current"] is None: await bot.reply(message, "Nothing playing") return track = ps["current"] progress = ps.get("progress") cur_seek = ps.get("cur_seek", 0.0) elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0) ps["paused"] = { "track": track, "elapsed": elapsed, "paused_at": time.monotonic(), } await _fade_and_cancel(bot) await bot.reply( message, f"Paused: {_truncate(track.title)} at {_fmt_time(elapsed)}", ) @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", aliases=["next"]) 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"] # Push skipped track to history ps["history"].append(_Track(url=skipped.url, title=skipped.title, requester=skipped.requester, origin=skipped.origin)) if len(ps["history"]) > _MAX_HISTORY: ps["history"].pop(0) await _fade_and_cancel(bot) _ensure_loop(bot) @command("prev", help="Music: !prev -- play previous track") async def cmd_prev(bot, message): """Go back to the previous track.""" if not _is_mumble(bot): return ps = _ps(bot) if not ps["history"]: await bot.reply(message, "No previous track") return prev = ps["history"].pop() # Re-queue current track so it plays next after prev if ps["current"] is not None: ps["queue"].insert(0, _Track(url=ps["current"].url, title=ps["current"].title, requester=ps["current"].requester, origin=ps["current"].origin)) ps["queue"].insert(0, prev) await _fade_and_cancel(bot) _ensure_loop(bot) await bot.reply(message, f"Previous: {_truncate(prev.title)}") @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 # 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) # Clamp to track duration (leave 1s margin so ffmpeg produces output) track = ps.get("current") if track and track.duration > 0 and target >= track.duration: target = max(0.0, track.duration - 1.0) seek_req = ps.get("seek_req") if not seek_req: await bot.reply(message, "Nothing playing") return seek_req[0] = target ps["cur_seek"] = target if ps.get("progress"): ps["progress"][0] = 0 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"]: track = ps["current"] progress = ps.get("progress") cur_seek = ps.get("cur_seek", 0.0) elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0) pos = _fmt_time(elapsed) if track.duration > 0: pos = f"{pos}/{_fmt_time(track.duration)}" lines.append( f"Now: {_truncate(track.title)}" f" [{track.requester}] ({pos})" ) if ps["queue"]: total_dur = 0.0 for i, track in enumerate(ps["queue"], 1): dur = f" ({_fmt_time(track.duration)})" if track.duration > 0 else "" total_dur += track.duration lines.append( f" {i}. {_truncate(track.title)} [{track.requester}]{dur}" ) count = len(ps["queue"]) footer = f"Queue: {count} track{'s' if count != 1 else ''}" if total_dur > 0: footer += f", {_fmt_time(total_dur)} total" lines.append(footer) 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"] progress = ps.get("progress") cur_seek = ps.get("cur_seek", 0.0) elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0) pos = _fmt_time(elapsed) if track.duration > 0: pos = f"{pos}/{_fmt_time(track.duration)}" await bot.reply( message, f"Now playing: {_truncate(track.title)} [{track.requester}]" f" ({pos})", ) @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("announce", help="Music: !announce [on|off] -- toggle track announcements") async def cmd_announce(bot, message): """Toggle automatic track announcements in the channel.""" if not _is_mumble(bot): return ps = _ps(bot) parts = message.text.split() if len(parts) >= 2: sub = parts[1].lower() if sub == "on": ps["announce"] = True elif sub == "off": ps["announce"] = False else: await bot.reply(message, "Usage: !announce [on|off]") return else: ps["announce"] = not ps["announce"] state = "on" if ps["announce"] else "off" await bot.reply(message, f"Track announcements: {state}") @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. Fetches metadata (title, artist, duration) and persists it alongside the filename so ``!kept`` can display useful information. """ 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: if not track.url: await bot.reply(message, "No local file for current track") return # Download on the spot -- track was streaming without a local file 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, track.title, ) if dl_path: track.local_path = dl_path else: await bot.reply(message, "Download failed, cannot keep track") return track.keep = True # Check if this track is already kept (by normalized URL) norm_url = _strip_playlist_params(track.url) for key in bot.state.keys("music"): if not key.startswith("keep:"): continue raw = bot.state.get("music", key) if not raw: continue try: existing = json.loads(raw) except (json.JSONDecodeError, TypeError): continue if _strip_playlist_params(existing.get("url", "")) == norm_url: kid = existing.get("id", key.split(":", 1)[1]) await bot.reply(message, f"Already kept as #{kid}") return # Assign a unique short ID last_id = int(bot.state.get("music", "keep_next_id") or "1") keep_id = last_id bot.state.set("music", "keep_next_id", str(last_id + 1)) # Fetch metadata in background loop = asyncio.get_running_loop() meta = await loop.run_in_executor(None, _fetch_metadata, track.url) # Move file from cache to kept directory with a clean name _MUSIC_DIR.mkdir(parents=True, exist_ok=True) tid = hashlib.md5(track.url.encode()).hexdigest()[:12] clean_name = _sanitize_filename(meta.get("title", ""), tid) ext = track.local_path.suffix dest = _MUSIC_DIR / f"{clean_name}{ext}" if track.local_path != dest and not dest.exists(): shutil.move(str(track.local_path), str(dest)) track.local_path = dest filename = track.local_path.name meta["filename"] = filename meta["url"] = track.url meta["id"] = keep_id bot.state.set("music", f"keep:{keep_id}", json.dumps(meta)) # Build display string title = meta.get("title") or track.title artist = meta.get("artist", "") dur = meta.get("duration", 0) label = _truncate(title) if artist and artist.lower() not in ("na", "unknown", ""): label += f" -- {artist}" if dur > 0: label += f" ({_fmt_time(dur)})" await bot.reply(message, f"Keeping #{keep_id}: {label}") @command("kept", help="Music: !kept [rm |clear|repair] -- manage kept files") async def cmd_kept(bot, message): """List, clear, remove, or repair kept audio files in data/music/. Usage: !kept List kept tracks with metadata and file status !kept rm Remove a single kept track by ID !kept clear Delete all kept files and metadata !kept repair Re-download kept tracks whose local files are missing """ if not _is_mumble(bot): await bot.reply(message, "Mumble-only feature") return parts = message.text.split() sub = parts[1].lower() if len(parts) >= 2 else "" if sub == "rm": if len(parts) < 3: await bot.reply(message, "Usage: !kept rm ") return kid = parts[2].lstrip("#") raw = bot.state.get("music", f"keep:{kid}") if not raw: await bot.reply(message, f"No kept track with ID #{kid}") return try: meta = json.loads(raw) except (json.JSONDecodeError, TypeError): meta = {} filename = meta.get("filename", "") if filename: fpath = _MUSIC_DIR / filename fpath.unlink(missing_ok=True) bot.state.delete("music", f"keep:{kid}") title = meta.get("title") or filename or kid await bot.reply(message, f"Removed #{kid}: {_truncate(title)}") # Skip if this track is currently playing ps = _ps(bot) cur = ps.get("current") if cur and filename and cur.local_path and cur.local_path.name == filename: await _fade_and_cancel(bot) _ensure_loop(bot) return if sub == "clear": count = 0 if _MUSIC_DIR.is_dir(): for f in _MUSIC_DIR.iterdir(): if f.is_file(): f.unlink() count += 1 # Clear stored metadata and reset ID counter for key in bot.state.keys("music"): if key.startswith("keep:") or key == "keep_next_id": bot.state.delete("music", key) await bot.reply(message, f"Deleted {count} file(s)") return if sub == "repair": await _kept_repair(bot, message) return # Collect kept entries from state entries = [] for key in bot.state.keys("music"): if not key.startswith("keep:"): continue raw = bot.state.get("music", key) if not raw: continue try: meta = json.loads(raw) except (json.JSONDecodeError, TypeError): continue entries.append(meta) if not entries: await bot.reply(message, "No kept tracks") return entries.sort(key=lambda m: m.get("id", 0)) lines = [f"Kept tracks ({len(entries)}):"] for meta in entries: kid = meta.get("id", "?") title = meta.get("title", "") artist = meta.get("artist", "") dur = meta.get("duration", 0) filename = meta.get("filename", "") label = _truncate(title) if title else filename if artist and artist.lower() not in ("na", "unknown", ""): label += f" -- {artist}" if dur > 0: label += f" ({_fmt_time(dur)})" # Show file size if file exists, or mark missing fpath = _MUSIC_DIR / filename if filename else None size = "" if fpath and fpath.is_file(): size = f" [{fpath.stat().st_size / (1024 * 1024):.1f}MB]" else: size = " [MISSING]" lines.append(f" #{kid} {label}{size}") await bot.long_reply(message, lines, label="kept tracks") async def _kept_repair(bot, message) -> None: """Re-download kept tracks whose local files are missing.""" entries = [] for key in bot.state.keys("music"): if not key.startswith("keep:"): continue raw = bot.state.get("music", key) if not raw: continue try: meta = json.loads(raw) except (json.JSONDecodeError, TypeError): continue filename = meta.get("filename", "") if not filename: continue fpath = _MUSIC_DIR / filename if not fpath.is_file(): entries.append((key, meta)) if not entries: await bot.reply(message, "All kept files present, nothing to repair") return await bot.reply(message, f"Repairing {len(entries)} missing file(s)...") _MUSIC_DIR.mkdir(parents=True, exist_ok=True) loop = asyncio.get_running_loop() repaired = 0 failed = 0 for key, meta in entries: kid = meta.get("id", "?") url = meta.get("url", "") title = meta.get("title", "") filename = meta["filename"] if not url: log.warning("music: repair #%s has no URL, skipping", kid) failed += 1 continue tid = hashlib.md5(url.encode()).hexdigest()[:12] dl_path = await loop.run_in_executor( None, _download_track, url, tid, title, ) if not dl_path: log.warning("music: repair #%s download failed", kid) failed += 1 continue # Move to kept directory with expected filename dest = _MUSIC_DIR / filename if dl_path != dest: # Extension may differ; update metadata if needed if dl_path.suffix != dest.suffix: new_filename = dest.stem + dl_path.suffix dest = _MUSIC_DIR / new_filename meta["filename"] = new_filename bot.state.set("music", key, json.dumps(meta)) shutil.move(str(dl_path), str(dest)) repaired += 1 log.info("music: repaired #%s -> %s", kid, dest.name) msg = f"Repair complete: {repaired} restored" if failed: msg += f", {failed} failed" await bot.reply(message, msg) @command("playlist", help="Music: !playlist save|load|list|del ") async def cmd_playlist(bot, message): """Save, load, list, delete, import, or show named playlists. Usage: !playlist save Save current + queued tracks as a playlist !playlist load [shuffle] Append saved playlist to queue !playlist list Show saved playlists with track counts !playlist del Delete a saved playlist !playlist import Import tracks from URL as a named playlist !playlist show Display tracks in a saved playlist """ if not _is_mumble(bot): await bot.reply(message, "Mumble-only feature") return parts = message.text.split() if len(parts) < 2: await bot.reply( message, "Usage: !playlist save|load|list|del|import|show ", ) return sub = parts[1].lower() if sub == "save": if len(parts) < 3: await bot.reply(message, "Usage: !playlist save ") return name = parts[2].lower() ps = _ps(bot) entries = [] if ps["current"]: t = ps["current"] entries.append({"url": t.url, "title": t.title, "requester": t.requester}) for t in ps["queue"]: entries.append({"url": t.url, "title": t.title, "requester": t.requester}) if not entries: await bot.reply(message, "Nothing to save") return bot.state.set("music", f"playlist:{name}", json.dumps(entries)) await bot.reply( message, f"Saved playlist '{name}' ({len(entries)} track" f"{'s' if len(entries) != 1 else ''})", ) elif sub == "load": if len(parts) < 3: await bot.reply(message, "Usage: !playlist load [shuffle]") return name = parts[2].lower() shuffle = len(parts) >= 4 and parts[3].lower() == "shuffle" raw = bot.state.get("music", f"playlist:{name}") if not raw: await bot.reply(message, f"No playlist named '{name}'") return try: entries = json.loads(raw) except (json.JSONDecodeError, TypeError): await bot.reply(message, f"Corrupt playlist '{name}'") return ps = _ps(bot) was_idle = ps["current"] is None added = 0 for e in entries: if len(ps["queue"]) >= _MAX_QUEUE: break ps["queue"].append(_Track( url=e["url"], title=e.get("title", e["url"]), requester=e.get("requester", "?"), )) added += 1 if shuffle and ps["queue"]: random.shuffle(ps["queue"]) suffix = " (shuffled)" if shuffle else "" await bot.reply( message, f"Loaded '{name}': {added} track{'s' if added != 1 else ''}{suffix}", ) if was_idle: _ensure_loop(bot) elif sub == "list": names = [] for key in bot.state.keys("music"): if not key.startswith("playlist:"): continue pname = key.split(":", 1)[1] raw = bot.state.get("music", key) count = 0 if raw: try: count = len(json.loads(raw)) except (json.JSONDecodeError, TypeError): pass names.append((pname, count)) if not names: await bot.reply(message, "No saved playlists") return names.sort() lines = [f"Playlists ({len(names)}):"] for pname, count in names: lines.append( f" {pname} ({count} track{'s' if count != 1 else ''})", ) for line in lines: await bot.reply(message, line) elif sub == "del": if len(parts) < 3: await bot.reply(message, "Usage: !playlist del ") return name = parts[2].lower() raw = bot.state.get("music", f"playlist:{name}") if not raw: await bot.reply(message, f"No playlist named '{name}'") return bot.state.delete("music", f"playlist:{name}") await bot.reply(message, f"Deleted playlist '{name}'") elif sub == "import": if len(parts) < 4: await bot.reply(message, "Usage: !playlist import ") return name = parts[2].lower() url = parts[3] await bot.reply(message, f"Importing '{name}' from URL...") loop = asyncio.get_running_loop() try: resolved = await loop.run_in_executor(None, _resolve_tracks, url) except Exception: await bot.reply(message, "Failed to resolve URL") return if not resolved: await bot.reply(message, "No tracks found") return requester = message.nick or "?" entries = [{"url": u, "title": t, "requester": requester} for u, t in resolved] bot.state.set("music", f"playlist:{name}", json.dumps(entries)) await bot.reply( message, f"Imported playlist '{name}' ({len(entries)} track" f"{'s' if len(entries) != 1 else ''})", ) elif sub == "show": if len(parts) < 3: await bot.reply(message, "Usage: !playlist show ") return name = parts[2].lower() raw = bot.state.get("music", f"playlist:{name}") if not raw: await bot.reply(message, f"No playlist named '{name}'") return try: entries = json.loads(raw) except (json.JSONDecodeError, TypeError): await bot.reply(message, f"Corrupt playlist '{name}'") return if not entries: await bot.reply(message, f"Playlist '{name}' is empty") return lines = [f"Playlist '{name}' ({len(entries)} tracks):"] for i, e in enumerate(entries, 1): title = _truncate(e.get("title", e["url"])) lines.append(f" {i:>2}. {title}") await bot.long_reply(message, lines, label=name) else: await bot.reply( message, "Usage: !playlist save|load|list|del|import|show ", ) # -- 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", )