feat: fade-out on skip/stop/prev, song metadata on keep
Some checks failed
Some checks failed
- 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:
179
plugins/music.py
179
plugins/music.py
@@ -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 --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user