feat: music library management, smooth fades, clickable URLs

- Audio-only downloads (-x), resume (-c), skip existing (--no-overwrites)
- Title-based filenames (e.g. never-gonna-give-you-up.opus)
- Separate cache (data/music/cache/) from kept tracks (data/music/)
- Kept track IDs: !keep assigns #id, !play #id, !kept shows IDs
- Linear fade-in (5s) and fade-out (3s) with volume-proportional step
- Fix ramp click: threshold-based convergence instead of float equality
- Clean up cache files for skipped/stopped tracks
- Auto-linkify URLs in Mumble text chat (clickable <a> tags)
- FlaskPaste links use /raw endpoint for direct content access
- Metadata fetch uses --no-playlist for reliable results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 08:11:29 +01:00
parent ad1de1653e
commit b88a459142
4 changed files with 224 additions and 85 deletions

View File

@@ -114,7 +114,7 @@ def _create_paste(base_url: str, content: str) -> str:
body = json.loads(resp.read()) body = json.loads(resp.read())
paste_id = body.get("id", "") paste_id = body.get("id", "")
if paste_id: if paste_id:
return f"{base_url}/{paste_id}" return f"{base_url}/{paste_id}/raw"
return body.get("url", "") return body.get("url", "")

View File

@@ -7,6 +7,8 @@ import hashlib
import json import json
import logging import logging
import random import random
import re
import shutil
import subprocess import subprocess
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -184,7 +186,8 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
# -- Download helpers -------------------------------------------------------- # -- Download helpers --------------------------------------------------------
_MUSIC_DIR = Path("data/music") _MUSIC_DIR = Path("data/music") # kept tracks (persistent)
_CACHE_DIR = Path("data/music/cache") # temporary playback downloads
def _fetch_metadata(url: str) -> dict: def _fetch_metadata(url: str) -> dict:
@@ -192,7 +195,8 @@ def _fetch_metadata(url: str) -> dict:
try: try:
result = subprocess.run( result = subprocess.run(
["yt-dlp", "--print", "title", "--print", "artist", ["yt-dlp", "--print", "title", "--print", "artist",
"--print", "duration", "--no-warnings", "--no-download", url], "--print", "duration", "--no-warnings", "--no-download",
"--no-playlist", url],
capture_output=True, text=True, timeout=15, capture_output=True, text=True, timeout=15,
) )
lines = result.stdout.strip().splitlines() lines = result.stdout.strip().splitlines()
@@ -209,20 +213,46 @@ def _fetch_metadata(url: str) -> dict:
return {"title": "", "artist": "", "duration": 0} return {"title": "", "artist": "", "duration": 0}
def _download_track(url: str, track_id: str) -> Path | None: def _sanitize_filename(title: str, fallback: str) -> str:
"""Download audio to data/music/. Blocking -- run in executor.""" """Convert a track title to a clean, filesystem-safe filename.
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
template = str(_MUSIC_DIR / f"{track_id}.%(ext)s") Keeps alphanumeric chars, hyphens, and underscores. Collapses
whitespace/separators into single hyphens. Falls back to the
URL-based hash if the title produces nothing usable.
"""
name = re.sub(r"[^\w\s-]", "", title.lower())
name = re.sub(r"[\s_-]+", "-", name).strip("-")
if not name:
return fallback
return name[:80]
def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
"""Download audio to cache dir. Blocking -- run in executor.
Checks the kept directory first (reuse kept files). New downloads
go to the cache dir and are cleaned up after playback unless kept.
"""
filename = _sanitize_filename(title, track_id) if title else track_id
# Reuse existing kept or cached file
for d in (_MUSIC_DIR, _CACHE_DIR):
for name in (filename, track_id):
existing = list(d.glob(f"{name}.*")) if d.is_dir() else []
if existing:
return existing[0]
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
template = str(_CACHE_DIR / f"{track_id}.%(ext)s")
try: try:
result = subprocess.run( result = subprocess.run(
["yt-dlp", "-f", "bestaudio", "--no-warnings", ["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites",
"-o", template, "--print", "after_move:filepath", url], "--no-warnings", "-o", template,
"--print", "after_move:filepath", url],
capture_output=True, text=True, timeout=300, capture_output=True, text=True, timeout=300,
) )
filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "" filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
if filepath and Path(filepath).is_file(): if filepath and Path(filepath).is_file():
return Path(filepath) return Path(filepath)
matches = list(_MUSIC_DIR.glob(f"{track_id}.*")) matches = list(_CACHE_DIR.glob(f"{track_id}.*"))
return matches[0] if matches else None return matches[0] if matches else None
except Exception: except Exception:
log.exception("download failed for %s", url) log.exception("download failed for %s", url)
@@ -415,7 +445,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
tid = hashlib.md5(track.url.encode()).hexdigest()[:12] tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
dl_path = await loop.run_in_executor( dl_path = await loop.run_in_executor(
None, _download_track, track.url, tid, None, _download_track, track.url, tid, track.title,
) )
if dl_path: if dl_path:
track.local_path = dl_path track.local_path = dl_path
@@ -440,6 +470,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
seek=cur_seek, seek=cur_seek,
progress=progress, progress=progress,
fade_step=lambda: ps.get("fade_step"), fade_step=lambda: ps.get("fade_step"),
fade_in=True,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
elapsed = cur_seek + progress[0] * 0.02 elapsed = cur_seek + progress[0] * 0.02
@@ -466,6 +497,10 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
# Clean up current track's cached file (skipped/stopped tracks)
current = ps.get("current")
if current:
_cleanup_track(current)
if duck_task and not duck_task.done(): if duck_task and not duck_task.done():
duck_task.cancel() duck_task.cancel()
ps["current"] = None ps["current"] = None
@@ -493,16 +528,24 @@ def _ensure_loop(bot, *, seek: float = 0.0) -> None:
_MAX_HISTORY = 10 _MAX_HISTORY = 10
async def _fade_and_cancel(bot, duration: float = 0.8) -> None: async def _fade_and_cancel(bot, duration: float = 3.0) -> None:
"""Fade audio to zero over ``duration`` seconds, then cancel the task.""" """Fade audio to zero over ``duration`` seconds, then cancel the task."""
ps = _ps(bot) ps = _ps(bot)
task = ps.get("task") task = ps.get("task")
if not task or task.done(): if not task or task.done():
return return
# Fast ramp: reach 0 from any volume in ~duration # Compute step from actual current volume so fade always spans `duration`.
# e.g. 0.8s = 40 frames at 20ms, step = 1.0/40 = 0.025 # At 3% vol: step = 0.03/40 = 0.00075 (still ~0.8s fade).
ps["fade_step"] = 1.0 / (duration / 0.02) # At 50% vol: step = 0.50/40 = 0.0125.
cur_vol = (
ps["duck_vol"] if ps["duck_vol"] is not None else ps["volume"]
) / 100.0
n_frames = max(duration / 0.02, 1)
step = max(cur_vol / n_frames, 0.0001)
ps["fade_step"] = step
ps["fade_vol"] = 0 ps["fade_vol"] = 0
log.debug("music: fading out (vol=%.2f, step=%.5f, duration=%.1fs)",
cur_vol, step, duration)
await asyncio.sleep(duration) await asyncio.sleep(duration)
ps["fade_step"] = None ps["fade_step"] = None
if not task.done(): if not task.done():
@@ -533,14 +576,45 @@ async def cmd_play(bot, message):
parts = message.text.split(None, 1) parts = message.text.split(None, 1)
if len(parts) < 2: if len(parts) < 2:
await bot.reply(message, "Usage: !play <url|query>") await bot.reply(message, "Usage: !play <url|query|#id>")
return return
url = parts[1].strip() url = parts[1].strip()
ps = _ps(bot)
# Play a kept track by ID: !play #3
if url.startswith("#"):
kid = url[1:]
raw = bot.state.get("music", f"keep:{kid}")
if not raw:
await bot.reply(message, f"No kept track with ID #{kid}")
return
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
await bot.reply(message, f"Bad metadata for #{kid}")
return
fpath = _MUSIC_DIR / meta.get("filename", "")
if not fpath.is_file():
await bot.reply(message, f"File missing for #{kid}")
return
title = meta.get("title") or meta.get("filename", kid)
track = _Track(url=meta.get("url", str(fpath)), title=title,
requester=message.nick or "?")
track.local_path = fpath
track.keep = True
was_idle = ps["current"] is None
ps["queue"].append(track)
if was_idle:
await bot.reply(message, f"Playing: {_truncate(title)}")
else:
await bot.reply(message, f"Queued #{len(ps['queue'])}: {_truncate(title)}")
_ensure_loop(bot)
return
is_search = not _is_url(url) is_search = not _is_url(url)
if is_search: if is_search:
url = f"ytsearch10:{url}" url = f"ytsearch10:{url}"
ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE: if len(ps["queue"]) >= _MAX_QUEUE:
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)") await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
@@ -972,26 +1046,42 @@ async def cmd_keep(bot, message):
await bot.reply(message, "No local file for current track") await bot.reply(message, "No local file for current track")
return return
track.keep = True track.keep = True
filename = track.local_path.name
# Assign a unique short ID
last_id = int(bot.state.get("music", "keep_next_id") or "1")
keep_id = last_id
bot.state.set("music", "keep_next_id", str(last_id + 1))
# Fetch metadata in background # Fetch metadata in background
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
meta = await loop.run_in_executor(None, _fetch_metadata, track.url) meta = await loop.run_in_executor(None, _fetch_metadata, track.url)
# Move file from cache to kept directory with a clean name
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
clean_name = _sanitize_filename(meta.get("title", ""), tid)
ext = track.local_path.suffix
dest = _MUSIC_DIR / f"{clean_name}{ext}"
if track.local_path != dest and not dest.exists():
shutil.move(str(track.local_path), str(dest))
track.local_path = dest
filename = track.local_path.name
meta["filename"] = filename meta["filename"] = filename
meta["url"] = track.url meta["url"] = track.url
bot.state.set("music", f"keep:{filename}", json.dumps(meta)) meta["id"] = keep_id
bot.state.set("music", f"keep:{keep_id}", json.dumps(meta))
# Build display string # Build display string
parts = []
title = meta.get("title") or track.title title = meta.get("title") or track.title
artist = meta.get("artist", "") artist = meta.get("artist", "")
dur = meta.get("duration", 0) dur = meta.get("duration", 0)
parts.append(_truncate(title)) label = _truncate(title)
if artist and artist.lower() not in ("na", "unknown", ""): if artist and artist.lower() not in ("na", "unknown", ""):
parts[0] += f" -- {artist}" label += f" -- {artist}"
if dur > 0: if dur > 0:
parts[0] += f" ({_fmt_time(dur)})" label += f" ({_fmt_time(dur)})"
await bot.reply(message, f"Keeping: {parts[0]}") 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] -- list or clear kept files")
@@ -1013,40 +1103,51 @@ async def cmd_kept(bot, message):
if f.is_file(): if f.is_file():
f.unlink() f.unlink()
count += 1 count += 1
# Clear stored metadata # Clear stored metadata and reset ID counter
for key in bot.state.keys("music"): for key in bot.state.keys("music"):
if key.startswith("keep:"): if key.startswith("keep:") or key == "keep_next_id":
bot.state.delete("music", key) bot.state.delete("music", key)
await bot.reply(message, f"Deleted {count} file(s)") await bot.reply(message, f"Deleted {count} file(s)")
return return
files = sorted(_MUSIC_DIR.iterdir()) if _MUSIC_DIR.is_dir() else [] # Collect kept entries from state
files = [f for f in files if f.is_file()] entries = []
if not files: for key in bot.state.keys("music"):
await bot.reply(message, "No kept files") 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
entries.append(meta)
if not entries:
await bot.reply(message, "No kept tracks")
return return
lines = [f"Kept files ({len(files)}):"] entries.sort(key=lambda m: m.get("id", 0))
for f in files: lines = [f"Kept tracks ({len(entries)}):"]
size_mb = f.stat().st_size / (1024 * 1024) for meta in entries:
raw = bot.state.get("music", f"keep:{f.name}") kid = meta.get("id", "?")
if raw: title = meta.get("title", "")
try: artist = meta.get("artist", "")
meta = json.loads(raw) dur = meta.get("duration", 0)
except (json.JSONDecodeError, TypeError): filename = meta.get("filename", "")
meta = {} label = _truncate(title) if title else filename
title = meta.get("title", "") if artist and artist.lower() not in ("na", "unknown", ""):
artist = meta.get("artist", "") label += f" -- {artist}"
dur = meta.get("duration", 0) if dur > 0:
label = _truncate(title) if title else f.name label += f" ({_fmt_time(dur)})"
if artist and artist.lower() not in ("na", "unknown", ""): # Show file size if file exists
label += f" -- {artist}" fpath = _MUSIC_DIR / filename if filename else None
if dur > 0: size = ""
label += f" ({_fmt_time(dur)})" if fpath and fpath.is_file():
lines.append(f" {label} [{size_mb:.1f}MB]") size = f" [{fpath.stat().st_size / (1024 * 1024):.1f}MB]"
else: lines.append(f" #{kid} {label}{size}")
lines.append(f" {f.name} ({size_mb:.1f}MB)") await bot.long_reply(message, lines, label="kept tracks")
await bot.long_reply(message, lines, label="kept files")
# -- Plugin lifecycle -------------------------------------------------------- # -- Plugin lifecycle --------------------------------------------------------

View File

@@ -45,9 +45,13 @@ def _strip_html(text: str) -> str:
return html.unescape(_TAG_RE.sub("", text)) return html.unescape(_TAG_RE.sub("", text))
_URL_RE = re.compile(r'(https?://[^\s<>&]+)')
def _escape_html(text: str) -> str: def _escape_html(text: str) -> str:
"""Escape text for Mumble HTML messages.""" """Escape text for Mumble HTML messages, auto-linking URLs."""
return html.escape(text, quote=False) escaped = html.escape(text, quote=False)
return _URL_RE.sub(r'<a href="\1">\1</a>', escaped)
def _shell_quote(s: str) -> str: def _shell_quote(s: str) -> str:
@@ -522,6 +526,7 @@ class MumbleBot:
seek: float = 0.0, seek: float = 0.0,
progress: list | None = None, progress: list | None = None,
fade_step=None, fade_step=None,
fade_in: bool = False,
) -> None: ) -> None:
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel. """Stream audio from URL through yt-dlp|ffmpeg to voice channel.
@@ -538,6 +543,8 @@ class MumbleBot:
current frame count each frame. ``fade_step`` is an optional current frame count each frame. ``fade_step`` is an optional
callable returning a float or None; when non-None it overrides callable returning a float or None; when non-None it overrides
the default ramp step for fast fades (e.g. skip/stop). the default ramp step for fast fades (e.g. skip/stop).
``fade_in`` starts playback from silence and ramps up to the
target volume over ~0.8s.
""" """
if self._mumble is None: if self._mumble is None:
return return
@@ -561,7 +568,12 @@ class MumbleBot:
) )
_max_step = 0.005 # max volume change per frame (~4s full ramp) _max_step = 0.005 # max volume change per frame (~4s full ramp)
_cur_vol = _get_vol() _fade_in_target = _get_vol()
_cur_vol = 0.0 if fade_in else _fade_in_target
# Fade-in: constant step to ramp linearly over ~5s (250 frames)
_fade_in_total = int(5.0 / 0.02) if fade_in else 0
_fade_in_frames = _fade_in_total
_fade_in_step = (_fade_in_target / _fade_in_total) if _fade_in_total else 0
_was_feeding = True # track connected/disconnected transitions _was_feeding = True # track connected/disconnected transitions
frames = 0 frames = 0
@@ -593,17 +605,21 @@ class MumbleBot:
target = _get_vol() target = _get_vol()
step = _max_step step = _max_step
if fade_step is not None: if _fade_in_frames > 0:
step = _fade_in_step
_fade_in_frames -= 1
elif fade_step is not None:
fs = fade_step() fs = fade_step()
if fs: if fs:
step = fs step = fs
if _cur_vol == target: diff = target - _cur_vol
# Fast path: flat scaling if abs(diff) < 0.0001:
# Close enough -- flat scaling (no ramp artifacts)
if target != 1.0: if target != 1.0:
pcm = _scale_pcm(pcm, target) pcm = _scale_pcm(pcm, target)
_cur_vol = target
else: else:
# Ramp toward target, clamped to step per frame # Ramp toward target, clamped to step per frame
diff = target - _cur_vol
if abs(diff) <= step: if abs(diff) <= step:
next_vol = target next_vol = target
elif diff > 0: elif diff > 0:

View File

@@ -1134,7 +1134,12 @@ class TestKeepCommand:
) )
ps["current"] = track ps["current"] = track
msg = _Msg(text="!keep") msg = _Msg(text="!keep")
asyncio.run(_mod.cmd_keep(bot, msg)) music_dir = tmp_path / "kept"
music_dir.mkdir()
meta = {"title": "t", "artist": "", "duration": 0}
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_fetch_metadata", return_value=meta):
asyncio.run(_mod.cmd_keep(bot, msg))
assert track.keep is True assert track.keep is True
assert any("Keeping" in r for r in bot.replied) assert any("Keeping" in r for r in bot.replied)
@@ -1151,23 +1156,27 @@ class TestKeepCommand:
class TestKeptCommand: class TestKeptCommand:
def test_kept_empty(self, tmp_path): def test_kept_empty(self):
bot = _FakeBot() bot = _FakeBot()
with patch.object(_mod, "_MUSIC_DIR", tmp_path / "empty"): msg = _Msg(text="!kept")
msg = _Msg(text="!kept") asyncio.run(_mod.cmd_kept(bot, msg))
asyncio.run(_mod.cmd_kept(bot, msg)) assert any("No kept tracks" in r for r in bot.replied)
assert any("No kept files" in r for r in bot.replied)
def test_kept_lists_files(self, tmp_path): def test_kept_lists_tracks(self, tmp_path):
bot = _FakeBot() bot = _FakeBot()
music_dir = tmp_path / "music" music_dir = tmp_path / "music"
music_dir.mkdir() music_dir.mkdir()
(music_dir / "abc123.opus").write_bytes(b"x" * 1024) (music_dir / "abc123.opus").write_bytes(b"x" * 1024)
bot.state.set("music", "keep:1", json.dumps({
"title": "Test Track", "artist": "", "duration": 0,
"filename": "abc123.opus", "id": 1,
}))
with patch.object(_mod, "_MUSIC_DIR", music_dir): with patch.object(_mod, "_MUSIC_DIR", music_dir):
msg = _Msg(text="!kept") msg = _Msg(text="!kept")
asyncio.run(_mod.cmd_kept(bot, msg)) asyncio.run(_mod.cmd_kept(bot, msg))
assert any("Kept files" in r for r in bot.replied) assert any("Kept tracks" in r for r in bot.replied)
assert any("abc123.opus" in r for r in bot.replied) assert any("#1" in r for r in bot.replied)
assert any("Test Track" in r for r in bot.replied)
def test_kept_clear(self, tmp_path): def test_kept_clear(self, tmp_path):
bot = _FakeBot() bot = _FakeBot()
@@ -1175,11 +1184,13 @@ class TestKeptCommand:
music_dir.mkdir() music_dir.mkdir()
(music_dir / "abc123.opus").write_bytes(b"audio") (music_dir / "abc123.opus").write_bytes(b"audio")
(music_dir / "def456.webm").write_bytes(b"audio") (music_dir / "def456.webm").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({"id": 1}))
with patch.object(_mod, "_MUSIC_DIR", music_dir): with patch.object(_mod, "_MUSIC_DIR", music_dir):
msg = _Msg(text="!kept clear") msg = _Msg(text="!kept clear")
asyncio.run(_mod.cmd_kept(bot, msg)) asyncio.run(_mod.cmd_kept(bot, msg))
assert any("Deleted 2 file(s)" in r for r in bot.replied) assert any("Deleted 2 file(s)" in r for r in bot.replied)
assert not list(music_dir.iterdir()) assert not list(music_dir.iterdir())
assert bot.state.get("music", "keep:1") is None
def test_kept_non_mumble(self): def test_kept_non_mumble(self):
bot = _FakeBot(mumble=False) bot = _FakeBot(mumble=False)
@@ -1533,7 +1544,7 @@ class TestFadeState:
class TestKeepMetadata: class TestKeepMetadata:
def test_keep_stores_metadata(self, tmp_path): def test_keep_stores_metadata(self, tmp_path):
"""!keep stores metadata JSON in bot.state.""" """!keep stores metadata JSON in bot.state keyed by ID."""
bot = _FakeBot() bot = _FakeBot()
ps = _mod._ps(bot) ps = _mod._ps(bot)
f = tmp_path / "abc123.opus" f = tmp_path / "abc123.opus"
@@ -1545,15 +1556,19 @@ class TestKeepMetadata:
ps["current"] = track ps["current"] = track
msg = _Msg(text="!keep") msg = _Msg(text="!keep")
meta = {"title": "My Song", "artist": "Artist", "duration": 195.0} meta = {"title": "My Song", "artist": "Artist", "duration": 195.0}
with patch.object(_mod, "_fetch_metadata", return_value=meta): music_dir = tmp_path / "kept"
music_dir.mkdir()
with patch.object(_mod, "_fetch_metadata", return_value=meta), \
patch.object(_mod, "_MUSIC_DIR", music_dir):
asyncio.run(_mod.cmd_keep(bot, msg)) asyncio.run(_mod.cmd_keep(bot, msg))
assert track.keep is True assert track.keep is True
raw = bot.state.get("music", "keep:abc123.opus") raw = bot.state.get("music", "keep:1")
assert raw is not None assert raw is not None
stored = json.loads(raw) stored = json.loads(raw)
assert stored["title"] == "My Song" assert stored["title"] == "My Song"
assert stored["artist"] == "Artist" assert stored["artist"] == "Artist"
assert stored["duration"] == 195.0 assert stored["duration"] == 195.0
assert stored["id"] == 1
assert any("My Song" in r for r in bot.replied) assert any("My Song" in r for r in bot.replied)
assert any("Artist" in r for r in bot.replied) assert any("Artist" in r for r in bot.replied)
assert any("3:15" in r for r in bot.replied) assert any("3:15" in r for r in bot.replied)
@@ -1571,7 +1586,10 @@ class TestKeepMetadata:
ps["current"] = track ps["current"] = track
msg = _Msg(text="!keep") msg = _Msg(text="!keep")
meta = {"title": "Song", "artist": "NA", "duration": 60.0} meta = {"title": "Song", "artist": "NA", "duration": 60.0}
with patch.object(_mod, "_fetch_metadata", return_value=meta): music_dir = tmp_path / "kept"
music_dir.mkdir()
with patch.object(_mod, "_fetch_metadata", return_value=meta), \
patch.object(_mod, "_MUSIC_DIR", music_dir):
asyncio.run(_mod.cmd_keep(bot, msg)) asyncio.run(_mod.cmd_keep(bot, msg))
# Should not contain "NA" as artist # Should not contain "NA" as artist
assert not any("NA" in r and "--" in r for r in bot.replied) assert not any("NA" in r and "--" in r for r in bot.replied)
@@ -1584,13 +1602,14 @@ class TestKeepMetadata:
class TestKeptMetadata: class TestKeptMetadata:
def test_kept_shows_metadata(self, tmp_path): def test_kept_shows_metadata(self, tmp_path):
"""!kept displays metadata from bot.state when available.""" """!kept displays metadata from bot.state."""
bot = _FakeBot() bot = _FakeBot()
music_dir = tmp_path / "music" music_dir = tmp_path / "music"
music_dir.mkdir() music_dir.mkdir()
(music_dir / "abc123.opus").write_bytes(b"x" * 2048) (music_dir / "abc123.opus").write_bytes(b"x" * 2048)
bot.state.set("music", "keep:abc123.opus", json.dumps({ bot.state.set("music", "keep:1", json.dumps({
"title": "Cool Song", "artist": "DJ Test", "duration": 225.0, "title": "Cool Song", "artist": "DJ Test", "duration": 225.0,
"filename": "abc123.opus", "id": 1,
})) }))
with patch.object(_mod, "_MUSIC_DIR", music_dir): with patch.object(_mod, "_MUSIC_DIR", music_dir):
msg = _Msg(text="!kept") msg = _Msg(text="!kept")
@@ -1598,31 +1617,34 @@ class TestKeptMetadata:
assert any("Cool Song" in r for r in bot.replied) assert any("Cool Song" in r for r in bot.replied)
assert any("DJ Test" in r for r in bot.replied) assert any("DJ Test" in r for r in bot.replied)
assert any("3:45" in r for r in bot.replied) assert any("3:45" in r for r in bot.replied)
assert any("#1" in r for r in bot.replied)
def test_kept_fallback_no_metadata(self, tmp_path): def test_kept_fallback_no_title(self):
"""!kept falls back to filename when no metadata stored.""" """!kept falls back to filename when no title in metadata."""
bot = _FakeBot() bot = _FakeBot()
music_dir = tmp_path / "music" bot.state.set("music", "keep:1", json.dumps({
music_dir.mkdir() "title": "", "artist": "", "duration": 0,
(music_dir / "xyz789.webm").write_bytes(b"x" * 1024) "filename": "xyz789.webm", "id": 1,
with patch.object(_mod, "_MUSIC_DIR", music_dir): }))
msg = _Msg(text="!kept") msg = _Msg(text="!kept")
asyncio.run(_mod.cmd_kept(bot, msg)) asyncio.run(_mod.cmd_kept(bot, msg))
assert any("xyz789.webm" in r for r in bot.replied) assert any("xyz789.webm" in r for r in bot.replied)
def test_kept_clear_removes_metadata(self, tmp_path): def test_kept_clear_removes_metadata(self, tmp_path):
"""!kept clear also removes stored metadata.""" """!kept clear also removes stored metadata and resets ID."""
bot = _FakeBot() bot = _FakeBot()
music_dir = tmp_path / "music" music_dir = tmp_path / "music"
music_dir.mkdir() music_dir.mkdir()
(music_dir / "abc123.opus").write_bytes(b"audio") (music_dir / "abc123.opus").write_bytes(b"audio")
bot.state.set("music", "keep:abc123.opus", json.dumps({ bot.state.set("music", "keep:1", json.dumps({
"title": "Song", "artist": "", "duration": 0, "title": "Song", "artist": "", "duration": 0, "id": 1,
})) }))
bot.state.set("music", "keep_next_id", "2")
with patch.object(_mod, "_MUSIC_DIR", music_dir): with patch.object(_mod, "_MUSIC_DIR", music_dir):
msg = _Msg(text="!kept clear") msg = _Msg(text="!kept clear")
asyncio.run(_mod.cmd_kept(bot, msg)) asyncio.run(_mod.cmd_kept(bot, msg))
assert bot.state.get("music", "keep:abc123.opus") is None assert bot.state.get("music", "keep:1") is None
assert bot.state.get("music", "keep_next_id") is None
assert any("Deleted 1 file(s)" in r for r in bot.replied) assert any("Deleted 1 file(s)" in r for r in bot.replied)