feat: playlist shuffle, lazy resolution, TTS ducking, kept repair
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 22s
CI / test (3.11) (push) Failing after 2m47s
CI / test (3.13) (push) Failing after 2m52s
CI / test (3.12) (push) Failing after 2m54s
CI / build (push) Has been skipped

Music:
- #random URL fragment shuffles playlist tracks before enqueuing
- Lazy playlist resolution: first 10 tracks resolve immediately,
  remaining are fetched in a background task
- !kept repair re-downloads kept tracks with missing local files
- !kept shows [MISSING] marker for tracks without local files
- TTS ducking: music ducks when merlin speaks via voice peer,
  smooth restore after TTS finishes

Performance (from profiling):
- Connection pool: preload_content=True for SOCKS connection reuse
- Pool tuning: 30 pools / 8 connections (up from 20/4)
- _PooledResponse wrapper for stdlib-compatible read interface
- Iterative _extract_videos (replace 51K-deep recursion with stack)
- proxy=False for local SearXNG

Voice + multi-bot:
- Per-bot voice config lookup ([<username>.voice] in TOML)
- Mute detection: skip duck silence when all users muted
- Autoplay shuffle deck (no repeats until full cycle)
- Seek clamp to track duration (prevent seek-past-end stall)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 16:21:47 +01:00
parent 6d6b957557
commit 6083de13f9
17 changed files with 1706 additions and 118 deletions

View File

@@ -21,6 +21,7 @@ log = logging.getLogger(__name__)
_MAX_QUEUE = 50
_MAX_TITLE_LEN = 80
_PLAYLIST_BATCH = 10 # initial tracks resolved before playback starts
@dataclass(slots=True)
@@ -31,6 +32,7 @@ class _Track:
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 ---------------------------------------------------
@@ -55,6 +57,9 @@ def _ps(bot):
"fade_step": None,
"history": [],
"autoplay": cfg.get("autoplay", True),
"autoplay_cooldown": cfg.get("autoplay_cooldown", 30),
"announce": cfg.get("announce", False),
"paused": None,
"_watcher_task": None,
})
@@ -171,27 +176,32 @@ def _clear_resume(bot) -> None:
bot.state.delete("music", "resume")
def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, str]]:
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. Falls back to ``[(url, url)]`` on error.
``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-end={max_tracks}", url,
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):
@@ -201,9 +211,22 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
if not track_url or track_url == "NA":
track_url = url
tracks.append((track_url, track_title or track_url))
return tracks if tracks else [(url, url)]
return tracks if tracks else ([] if start > 1 else [(url, url)])
except Exception:
return [(url, url)]
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 --------------------------------------------------------
@@ -299,6 +322,30 @@ def _cleanup_track(track: _Track) -> None:
# -- 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.
@@ -319,10 +366,15 @@ async def _duck_monitor(bot) -> None:
restore_start = 0.0
continue
ts = getattr(bot.registry, "_voice_ts", 0.0)
if 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 silence < ps["duck_silence"]:
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%%",
@@ -387,6 +439,8 @@ async def _auto_resume(bot) -> None:
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 -- "
@@ -438,12 +492,13 @@ def _load_kept_tracks(bot) -> list[_Track]:
requester="autoplay",
local_path=fpath,
keep=True,
duration=float(meta.get("duration", 0)),
))
return tracks
async def _autoplay_kept(bot) -> None:
"""Shuffle kept tracks and start playback when idle after reconnect."""
"""Start autoplay loop -- the play loop handles silence-wait + random pick."""
ps = _ps(bot)
if ps["current"] is not None:
return
@@ -455,31 +510,10 @@ async def _autoplay_kept(bot) -> None:
# Let pymumble fully stabilize
await asyncio.sleep(10)
# Wait for silence
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"Shuffling {len(kept)} kept tracks once silent")
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
else:
log.info("music: autoplay aborted, channel not silent after 60s")
return
if ps["current"] is not None:
return
random.shuffle(kept)
ps["queue"].extend(kept)
log.info("music: autoplay %d kept tracks (shuffled)", len(kept))
log.info("music: autoplay starting (%d kept tracks available)", len(kept))
_ensure_loop(bot)
@@ -526,12 +560,43 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
first = True
seek_req = [None]
ps["seek_req"] = seek_req
_autoplay_pool: list[_Track] = [] # shuffled deck, refilled each cycle
try:
while ps["queue"]:
while ps["queue"] or ps.get("autoplay"):
# Autoplay: cooldown + silence wait, then pick next from shuffled deck
if not ps["queue"]:
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))
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
pick = _autoplay_pool.pop(0)
ps["queue"].append(pick)
log.info("music: autoplay picked '%s' (%d 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
@@ -561,6 +626,30 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
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,
@@ -589,6 +678,8 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
if elapsed > 1.0:
_save_resume(bot, track, elapsed)
break
finally:
save_task.cancel()
await done.wait()
if progress[0] > 0:
@@ -604,8 +695,9 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
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:
if current and ps.get("paused") is None:
_cleanup_track(current)
if duck_task and not duck_task.done():
duck_task.cancel()
@@ -654,6 +746,9 @@ async def _fade_and_cancel(bot, duration: float = 3.0) -> None:
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()
@@ -663,6 +758,36 @@ async def _fade_and_cancel(bot, duration: float = 3.0) -> None:
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 ----------------------------------------------------------------
@@ -719,6 +844,12 @@ async def cmd_play(bot, message):
_ensure_loop(bot)
return
# 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}"
@@ -728,26 +859,43 @@ async def cmd_play(bot, message):
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, remaining)
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 "?"
added = 0
# Only set origin for direct URLs (not searches) so resume uses the
# resolved video URL rather than an ephemeral search query
origin = url if not is_search else ""
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
total_resolved = len(resolved)
# 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:
@@ -755,13 +903,18 @@ async def cmd_play(bot, message):
else:
pos = len(ps["queue"])
await bot.reply(message, f"Queued #{pos}: {title}")
elif added < total_resolved:
elif has_more:
await bot.reply(
message,
f"Queued {added} of {total_resolved} tracks (queue full)",
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")
await bot.reply(message, f"Queued {added} tracks{shuffled}")
if was_idle:
_ensure_loop(bot)
@@ -775,6 +928,7 @@ async def cmd_stop(bot, message):
ps = _ps(bot)
ps["queue"].clear()
ps["paused"] = None
task = ps.get("task")
if task and not task.done():
@@ -793,6 +947,75 @@ async def cmd_stop(bot, message):
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.
@@ -925,6 +1148,11 @@ async def cmd_seek(bot, message):
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")
@@ -988,10 +1216,13 @@ async def cmd_np(bot, message):
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" ({_fmt_time(elapsed)})",
f" ({pos})",
)
@@ -1134,6 +1365,29 @@ async def cmd_duck(bot, message):
)
@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.
@@ -1209,19 +1463,23 @@ async def cmd_keep(bot, message):
await bot.reply(message, f"Keeping #{keep_id}: {label}")
@command("kept", help="Music: !kept [clear] -- list or clear kept files")
@command("kept", help="Music: !kept [clear|repair] -- list, clear, or repair kept files")
async def cmd_kept(bot, message):
"""List or clear kept audio files in data/music/.
"""List, clear, or repair kept audio files in data/music/.
When metadata is available (from ``!keep``), displays title, artist,
duration, and file size. Falls back to filename + size otherwise.
Usage:
!kept List kept tracks with metadata and file status
!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()
if len(parts) >= 2 and parts[1].lower() == "clear":
sub = parts[1].lower() if len(parts) >= 2 else ""
if sub == "clear":
count = 0
if _MUSIC_DIR.is_dir():
for f in _MUSIC_DIR.iterdir():
@@ -1235,6 +1493,10 @@ async def cmd_kept(bot, message):
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"):
@@ -1266,15 +1528,86 @@ async def cmd_kept(bot, message):
label += f" -- {artist}"
if dur > 0:
label += f" ({_fmt_time(dur)})"
# Show file size if file exists
# 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)
# -- Plugin lifecycle --------------------------------------------------------