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:
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 --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user