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

@@ -368,45 +368,56 @@ def _fetch_og_batch(urls: list[str]) -> dict[str, tuple[str, str, str]]:
# -- YouTube InnerTube search (blocking) ------------------------------------
def _extract_videos(obj: object, depth: int = 0) -> list[dict]:
"""Recursively walk YouTube JSON to find video results.
"""Walk YouTube JSON to find video results (iterative).
Finds all objects containing both 'videoId' and 'title' keys.
Resilient to YouTube rearranging wrapper layers.
Uses an explicit stack instead of recursion to avoid 50K+ call
overhead on deeply nested InnerTube responses.
"""
if depth > 20:
return []
results = []
if isinstance(obj, dict):
video_id = obj.get("videoId")
title_obj = obj.get("title")
if isinstance(video_id, str) and video_id and title_obj is not None:
if isinstance(title_obj, dict):
runs = title_obj.get("runs", [])
title = "".join(r.get("text", "") for r in runs if isinstance(r, dict))
elif isinstance(title_obj, str):
title = title_obj
else:
title = ""
if title:
# Extract relative publish time (e.g. "2 days ago")
pub_obj = obj.get("publishedTimeText")
date = ""
if isinstance(pub_obj, dict):
date = pub_obj.get("simpleText", "")
elif isinstance(pub_obj, str):
date = pub_obj
results.append({
"id": video_id,
"title": title,
"url": f"https://www.youtube.com/watch?v={video_id}",
"date": date,
"extra": "",
})
for val in obj.values():
results.extend(_extract_videos(val, depth + 1))
elif isinstance(obj, list):
for item in obj:
results.extend(_extract_videos(item, depth + 1))
_MAX_DEPTH = 20
results: list[dict] = []
# Stack of (node, depth) tuples
stack: list[tuple[object, int]] = [(obj, 0)]
while stack:
node, d = stack.pop()
if d > _MAX_DEPTH:
continue
if isinstance(node, dict):
video_id = node.get("videoId")
title_obj = node.get("title")
if isinstance(video_id, str) and video_id and title_obj is not None:
if isinstance(title_obj, dict):
runs = title_obj.get("runs", [])
title = "".join(
r.get("text", "") for r in runs if isinstance(r, dict)
)
elif isinstance(title_obj, str):
title = title_obj
else:
title = ""
if title:
pub_obj = node.get("publishedTimeText")
date = ""
if isinstance(pub_obj, dict):
date = pub_obj.get("simpleText", "")
elif isinstance(pub_obj, str):
date = pub_obj
results.append({
"id": video_id,
"title": title,
"url": f"https://www.youtube.com/watch?v={video_id}",
"date": date,
"extra": "",
})
# Reverse to preserve original traversal order (stack is LIFO)
children = [v for v in node.values() if isinstance(v, (dict, list))]
for val in reversed(children):
stack.append((val, d + 1))
elif isinstance(node, list):
for item in reversed(node):
if isinstance(item, (dict, list)):
stack.append((item, d + 1))
return results

272
plugins/lastfm.py Normal file
View File

@@ -0,0 +1,272 @@
"""Plugin: music discovery via Last.fm API."""
from __future__ import annotations
import json
import logging
import os
import random
from urllib.parse import urlencode
from derp.plugin import command
log = logging.getLogger(__name__)
_BASE = "https://ws.audioscrobbler.com/2.0/"
# -- Config ------------------------------------------------------------------
def _get_api_key(bot) -> str:
"""Resolve Last.fm API key from env or config."""
return (os.environ.get("LASTFM_API_KEY", "")
or bot.config.get("lastfm", {}).get("api_key", ""))
# -- API helpers -------------------------------------------------------------
def _api_call(api_key: str, method: str, **params) -> dict:
"""Blocking Last.fm API call. Run in executor."""
from derp.http import urlopen
qs = urlencode({
"method": method,
"api_key": api_key,
"format": "json",
**params,
})
url = f"{_BASE}?{qs}"
try:
resp = urlopen(url, timeout=10)
return json.loads(resp.read().decode())
except Exception:
log.exception("lastfm: API call failed: %s", method)
return {}
def _get_similar_artists(api_key: str, artist: str,
limit: int = 10) -> list[dict]:
"""Fetch similar artists for a given artist name."""
data = _api_call(api_key, "artist.getSimilar",
artist=artist, limit=str(limit))
artists = data.get("similarartists", {}).get("artist", [])
if isinstance(artists, dict):
artists = [artists]
return artists
def _get_top_tags(api_key: str, artist: str) -> list[dict]:
"""Fetch top tags for an artist."""
data = _api_call(api_key, "artist.getTopTags", artist=artist)
tags = data.get("toptags", {}).get("tag", [])
if isinstance(tags, dict):
tags = [tags]
return tags
def _get_similar_tracks(api_key: str, artist: str, track: str,
limit: int = 10) -> list[dict]:
"""Fetch similar tracks for a given artist + track."""
data = _api_call(api_key, "track.getSimilar",
artist=artist, track=track, limit=str(limit))
tracks = data.get("similartracks", {}).get("track", [])
if isinstance(tracks, dict):
tracks = [tracks]
return tracks
def _search_track(api_key: str, query: str,
limit: int = 5) -> list[dict]:
"""Search Last.fm for tracks matching a query."""
data = _api_call(api_key, "track.search",
track=query, limit=str(limit))
results = data.get("results", {}).get("trackmatches", {}).get("track", [])
if isinstance(results, dict):
results = [results]
return results
# -- Metadata extraction -----------------------------------------------------
def _current_meta(bot) -> tuple[str, str]:
"""Extract artist and title from the currently playing track.
Returns (artist, title). Either or both may be empty.
Tries the music plugin's current track metadata, falling back to
splitting the title on common separators.
"""
music_ps = bot._pstate.get("music", {})
current = music_ps.get("current")
if current is None:
return ("", "")
raw_title = current.title or ""
# Try common "Artist - Title" patterns
for sep in (" - ", " -- ", " | ", " ~ "):
if sep in raw_title:
parts = raw_title.split(sep, 1)
return (parts[0].strip(), parts[1].strip())
# No separator -- treat whole thing as a search query
return ("", raw_title)
# -- Formatting --------------------------------------------------------------
def _fmt_match(m: float | str) -> str:
"""Format a Last.fm match score as a percentage."""
try:
return f"{float(m) * 100:.0f}%"
except (ValueError, TypeError):
return ""
# -- Commands ----------------------------------------------------------------
@command("similar", help="Music: !similar [artist|play] -- find similar music")
async def cmd_similar(bot, message):
"""Find similar artists or tracks.
Usage:
!similar Similar to currently playing track
!similar <artist> Similar artists to named artist
!similar play Queue a random similar track
!similar play <artist> Queue a similar track for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 2)
# !similar play [artist]
play_mode = len(parts) >= 2 and parts[1].lower() == "play"
if play_mode:
query = parts[2].strip() if len(parts) > 2 else ""
else:
query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop()
# Resolve artist from query or current track
if query:
artist = query
title = ""
else:
artist, title = _current_meta(bot)
if not artist and not title:
await bot.reply(message, "Nothing playing and no artist given")
return
# Try track-level similarity first if we have both artist + title
similar = []
if artist and title:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title,
)
# Fall back to artist-level similarity
if not similar:
search_artist = artist or title
similar_artists = await loop.run_in_executor(
None, _get_similar_artists, api_key, search_artist,
)
if not similar_artists:
await bot.reply(message, f"No similar artists found for '{search_artist}'")
return
if play_mode:
# Pick a random similar artist and search YouTube
pick = random.choice(similar_artists[:10])
pick_name = pick.get("name", "")
if not pick_name:
await bot.reply(message, "No playable result found")
return
# Inject a !play command with a YouTube search
message.text = f"!play {pick_name}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
# Display similar artists
lines = [f"Similar to {search_artist}:"]
for a in similar_artists[:8]:
name = a.get("name", "?")
match = _fmt_match(a.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {name}{suffix}")
await bot.long_reply(message, lines, label="similar artists")
return
# Track-level results
if play_mode:
pick = random.choice(similar[:10])
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
search = f"{pick_artist} {pick_title}".strip()
if not search:
await bot.reply(message, "No playable result found")
return
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
# Display similar tracks
lines = [f"Similar to {artist} - {title}:"]
for t in similar[:8]:
t_artist = t.get("artist", {}).get("name", "")
t_name = t.get("name", "?")
match = _fmt_match(t.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {t_artist} - {t_name}{suffix}")
await bot.long_reply(message, lines, label="similar tracks")
@command("tags", help="Music: !tags [artist] -- show genre tags")
async def cmd_tags(bot, message):
"""Show genre/style tags for an artist.
Usage:
!tags Tags for currently playing artist
!tags <artist> Tags for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 1)
query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop()
if query:
artist = query
else:
artist, title = _current_meta(bot)
artist = artist or title
if not artist:
await bot.reply(message, "Nothing playing and no artist given")
return
tags = await loop.run_in_executor(
None, _get_top_tags, api_key, artist,
)
if not tags:
await bot.reply(message, f"No tags found for '{artist}'")
return
# Show top tags with counts
tag_names = [t.get("name", "?") for t in tags[:10] if t.get("name")]
await bot.reply(message, f"{artist}: {', '.join(tag_names)}")

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 --------------------------------------------------------

View File

@@ -6,6 +6,7 @@ import json
import urllib.parse
import urllib.request
from derp.http import urlopen as _urlopen
from derp.plugin import command
# -- Constants ---------------------------------------------------------------
@@ -38,7 +39,7 @@ def _search(query: str) -> list[dict]:
url = f"{_SEARX_URL}?{params}"
req = urllib.request.Request(url, method="GET")
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
resp = _urlopen(req, timeout=_FETCH_TIMEOUT, proxy=False)
raw = resp.read()
resp.close()

View File

@@ -38,6 +38,18 @@ _MAX_SAY_LEN = 500 # max characters for !say
_WHISPER_URL = "http://192.168.129.9:8080/inference"
_PIPER_URL = "http://192.168.129.9:5100/"
def _find_voice_peer(bot):
"""Find the voice-capable peer (the bot with 'voice' in only_plugins)."""
bots = getattr(bot.registry, "_bots", {})
for name, b in bots.items():
if name == bot._username:
continue
if getattr(b, "_only_plugins", None) and "voice" in b._only_plugins:
return b
return None
# -- Per-bot state -----------------------------------------------------------
@@ -172,8 +184,10 @@ async def _flush_monitor(bot):
remainder = text[len(trigger):].strip()
if remainder:
log.info("voice: trigger from %s: %s", name, remainder)
bot._spawn(
_tts_play(bot, remainder), name="voice-tts",
# Route TTS through voice-capable peer if available
speaker = _find_voice_peer(bot) or bot
speaker._spawn(
_tts_play(speaker, remainder), name="voice-tts",
)
continue
@@ -242,10 +256,13 @@ async def _tts_play(bot, text: str):
if wav_path is None:
return
try:
# Signal music plugin to duck while TTS is playing
bot.registry._tts_active = True
done = asyncio.Event()
await bot.stream_audio(str(wav_path), volume=1.0, on_done=done)
await done.wait()
finally:
bot.registry._tts_active = False
Path(wav_path).unlink(missing_ok=True)