feat: playlist shuffle, lazy resolution, TTS ducking, kept repair
Some checks failed
Some checks failed
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:
@@ -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
272
plugins/lastfm.py
Normal 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)}")
|
||||
425
plugins/music.py
425
plugins/music.py
@@ -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 --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user