Files
derp/plugins/music.py
user da9ed51c74 feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm
for similar tracks. When Last.fm has no key or returns nothing, fall back
to MusicBrainz tag-based recording search (no API key needed). Discovered
tracks are resolved via yt-dlp and deduplicated within the session. If
discovery fails, the kept-deck shuffle continues as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:19:41 +01:00

1925 lines
65 KiB
Python

"""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 <url|query>")
async def cmd_play(bot, message):
"""Play a URL or add to queue if already playing.
Usage:
!play <url> Play audio from URL (YouTube, SoundCloud, etc.)
!play <query> 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 <url|query|#id>")
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 <offset>")
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 <offset> (e.g. 1:30, +30, -30)")
return
try:
mode, seconds = _parse_seek(parts[1].strip())
except ValueError:
await bot.reply(message, "Usage: !seek <offset> (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 <url> 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 <sec> Set silence timeout (seconds)
!duck restore <sec> 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 <seconds>")
return
try:
val = int(parts[2])
except ValueError:
await bot.reply(message, "Usage: !duck silence <seconds>")
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 <seconds>")
return
try:
val = int(parts[2])
except ValueError:
await bot.reply(message, "Usage: !duck restore <seconds>")
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 <id>|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 <id> 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 <id>")
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 <name>")
async def cmd_playlist(bot, message):
"""Save, load, list, delete, import, or show named playlists.
Usage:
!playlist save <name> Save current + queued tracks as a playlist
!playlist load <name> [shuffle] Append saved playlist to queue
!playlist list Show saved playlists with track counts
!playlist del <name> Delete a saved playlist
!playlist import <name> <url> Import tracks from URL as a named playlist
!playlist show <name> 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 <name>",
)
return
sub = parts[1].lower()
if sub == "save":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist save <name>")
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 <name> [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 <name>")
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 <name> <url>")
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 <name>")
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 <name>",
)
# -- 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",
)