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>
1925 lines
65 KiB
Python
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",
|
|
)
|