feat: fade-out on skip/stop/prev, song metadata on keep
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Successful in 24s
CI / test (3.11) (push) Failing after 30s
CI / test (3.13) (push) Failing after 34s
CI / test (3.12) (push) Failing after 36s
CI / build (push) Has been skipped

- Add fade_step parameter to stream_audio for fast volume ramps
- _fade_and_cancel helper: smooth ~0.8s fade before track switch
- !skip, !stop, !seek now fade out instead of cutting instantly
- !prev command: go back to previous track (10-track history stack)
- !keep fetches title/artist/duration via yt-dlp, stores in bot.state
- !kept displays metadata (title, artist, duration, file size)
- !kept clear also removes stored metadata
- 29 new tests for fade, prev, history, keep metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 06:38:25 +01:00
parent de2d1fdf15
commit 8f1df167b9
5 changed files with 487 additions and 43 deletions

View File

@@ -48,6 +48,9 @@ def _ps(bot):
"duck_restore": cfg.get("duck_restore", 30),
"duck_vol": None,
"duck_task": None,
"fade_vol": None,
"fade_step": None,
"history": [],
"_watcher_task": None,
})
@@ -184,6 +187,28 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
_MUSIC_DIR = Path("data/music")
def _fetch_metadata(url: str) -> dict:
"""Fetch track metadata via yt-dlp. Blocking -- run in executor."""
try:
result = subprocess.run(
["yt-dlp", "--print", "title", "--print", "artist",
"--print", "duration", "--no-warnings", "--no-download", url],
capture_output=True, text=True, timeout=15,
)
lines = result.stdout.strip().splitlines()
return {
"title": lines[0] if len(lines) > 0 else "",
"artist": lines[1] if len(lines) > 1 else "",
"duration": (float(lines[2])
if len(lines) > 2
and lines[2].replace(".", "", 1).isdigit()
else 0),
}
except Exception:
log.warning("music: metadata fetch failed for %s", url)
return {"title": "", "artist": "", "duration": 0}
def _download_track(url: str, track_id: str) -> Path | None:
"""Download audio to data/music/. Blocking -- run in executor."""
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
@@ -372,6 +397,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
while ps["queue"]:
track = ps["queue"].pop(0)
ps["current"] = track
ps["fade_vol"] = None
ps["fade_step"] = None
done = asyncio.Event()
ps["done_event"] = done
@@ -403,13 +430,16 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
await bot.stream_audio(
source,
volume=lambda: (
ps["duck_vol"]
ps["fade_vol"]
if ps["fade_vol"] is not None
else ps["duck_vol"]
if ps["duck_vol"] is not None
else ps["volume"]
) / 100.0,
on_done=done,
seek=cur_seek,
progress=progress,
fade_step=lambda: ps.get("fade_step"),
)
except asyncio.CancelledError:
elapsed = cur_seek + progress[0] * 0.02
@@ -426,6 +456,12 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
await done.wait()
if progress[0] > 0:
_clear_resume(bot)
# Push finished track to history
ps["history"].append(_Track(url=track.url, title=track.title,
requester=track.requester,
origin=track.origin))
if len(ps["history"]) > _MAX_HISTORY:
ps["history"].pop(0)
_cleanup_track(track)
except asyncio.CancelledError:
pass
@@ -437,6 +473,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
ps["task"] = None
ps["duck_vol"] = None
ps["duck_task"] = None
ps["fade_vol"] = None
ps["fade_step"] = None
ps["progress"] = None
ps["cur_seek"] = 0.0
@@ -452,6 +490,29 @@ def _ensure_loop(bot, *, seek: float = 0.0) -> None:
)
_MAX_HISTORY = 10
async def _fade_and_cancel(bot, duration: float = 0.8) -> None:
"""Fade audio to zero over ``duration`` seconds, then cancel the task."""
ps = _ps(bot)
task = ps.get("task")
if not task or task.done():
return
# Fast ramp: reach 0 from any volume in ~duration
# e.g. 0.8s = 40 frames at 20ms, step = 1.0/40 = 0.025
ps["fade_step"] = 1.0 / (duration / 0.02)
ps["fade_vol"] = 0
await asyncio.sleep(duration)
ps["fade_step"] = None
if not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
# -- Commands ----------------------------------------------------------------
@@ -536,17 +597,15 @@ async def cmd_stop(bot, message):
task = ps.get("task")
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
await _fade_and_cancel(bot)
else:
ps["current"] = None
ps["task"] = None
ps["done_event"] = None
ps["duck_vol"] = None
ps["duck_task"] = None
ps["fade_vol"] = None
ps["fade_step"] = None
ps["progress"] = None
ps["cur_seek"] = 0.0
@@ -602,14 +661,14 @@ async def cmd_skip(bot, message):
return
skipped = ps["current"]
# Push skipped track to history
ps["history"].append(_Track(url=skipped.url, title=skipped.title,
requester=skipped.requester,
origin=skipped.origin))
if len(ps["history"]) > _MAX_HISTORY:
ps["history"].pop(0)
task = ps.get("task")
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
await _fade_and_cancel(bot)
if ps["queue"]:
_ensure_loop(bot)
@@ -621,6 +680,33 @@ async def cmd_skip(bot, message):
await bot.reply(message, "Skipped, queue empty")
@command("prev", help="Music: !prev -- play previous track")
async def cmd_prev(bot, message):
"""Go back to the previous track."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if not ps["history"]:
await bot.reply(message, "No previous track")
return
prev = ps["history"].pop()
# Re-queue current track so it plays next after prev
if ps["current"] is not None:
ps["queue"].insert(0, _Track(url=ps["current"].url,
title=ps["current"].title,
requester=ps["current"].requester,
origin=ps["current"].origin))
ps["queue"].insert(0, prev)
await _fade_and_cancel(bot)
_ensure_loop(bot)
await bot.reply(message, f"Previous: {_truncate(prev.title)}")
@command("seek", help="Music: !seek <offset>")
async def cmd_seek(bot, message):
"""Seek to position in current track.
@@ -666,14 +752,7 @@ async def cmd_seek(bot, message):
# Re-insert current track at front of queue (local_path intact)
ps["queue"].insert(0, track)
# Cancel the play loop and wait for cleanup
task = ps.get("task")
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
await _fade_and_cancel(bot)
_ensure_loop(bot, seek=target)
await bot.reply(message, f"Seeking to {_fmt_time(target)}")
@@ -875,7 +954,11 @@ async def cmd_duck(bot, message):
@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."""
"""Mark the current track's local file to keep after playback.
Fetches metadata (title, artist, duration) and persists it alongside
the filename so ``!kept`` can display useful information.
"""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
@@ -889,12 +972,35 @@ async def cmd_keep(bot, message):
await bot.reply(message, "No local file for current track")
return
track.keep = True
await bot.reply(message, f"Keeping: {track.local_path.name}")
filename = track.local_path.name
# Fetch metadata in background
loop = asyncio.get_running_loop()
meta = await loop.run_in_executor(None, _fetch_metadata, track.url)
meta["filename"] = filename
meta["url"] = track.url
bot.state.set("music", f"keep:{filename}", json.dumps(meta))
# Build display string
parts = []
title = meta.get("title") or track.title
artist = meta.get("artist", "")
dur = meta.get("duration", 0)
parts.append(_truncate(title))
if artist and artist.lower() not in ("na", "unknown", ""):
parts[0] += f" -- {artist}"
if dur > 0:
parts[0] += f" ({_fmt_time(dur)})"
await bot.reply(message, f"Keeping: {parts[0]}")
@command("kept", help="Music: !kept [clear] -- list or clear kept files")
async def cmd_kept(bot, message):
"""List or clear kept audio files in data/music/."""
"""List or clear 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.
"""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
@@ -907,6 +1013,10 @@ async def cmd_kept(bot, message):
if f.is_file():
f.unlink()
count += 1
# Clear stored metadata
for key in bot.state.keys("music"):
if key.startswith("keep:"):
bot.state.delete("music", key)
await bot.reply(message, f"Deleted {count} file(s)")
return
@@ -919,9 +1029,24 @@ async def cmd_kept(bot, message):
lines = [f"Kept files ({len(files)}):"]
for f in files:
size_mb = f.stat().st_size / (1024 * 1024)
lines.append(f" {f.name} ({size_mb:.1f}MB)")
for line in lines:
await bot.reply(message, line)
raw = bot.state.get("music", f"keep:{f.name}")
if raw:
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
meta = {}
title = meta.get("title", "")
artist = meta.get("artist", "")
dur = meta.get("duration", 0)
label = _truncate(title) if title else f.name
if artist and artist.lower() not in ("na", "unknown", ""):
label += f" -- {artist}"
if dur > 0:
label += f" ({_fmt_time(dur)})"
lines.append(f" {label} [{size_mb:.1f}MB]")
else:
lines.append(f" {f.name} ({size_mb:.1f}MB)")
await bot.long_reply(message, lines, label="kept files")
# -- Plugin lifecycle --------------------------------------------------------