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:
@@ -114,7 +114,7 @@ def _create_paste(base_url: str, content: str) -> str:
|
||||
body = json.loads(resp.read())
|
||||
paste_id = body.get("id", "")
|
||||
if paste_id:
|
||||
return f"{base_url}/{paste_id}"
|
||||
return f"{base_url}/{paste_id}/raw"
|
||||
return body.get("url", "")
|
||||
|
||||
|
||||
|
||||
201
plugins/music.py
201
plugins/music.py
@@ -7,6 +7,8 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -184,7 +186,8 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
||||
# -- 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:
|
||||
@@ -192,7 +195,8 @@ def _fetch_metadata(url: str) -> dict:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["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,
|
||||
)
|
||||
lines = result.stdout.strip().splitlines()
|
||||
@@ -209,20 +213,46 @@ def _fetch_metadata(url: str) -> dict:
|
||||
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)
|
||||
template = str(_MUSIC_DIR / f"{track_id}.%(ext)s")
|
||||
def _sanitize_filename(title: str, fallback: str) -> str:
|
||||
"""Convert a track title to a clean, filesystem-safe filename.
|
||||
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "-f", "bestaudio", "--no-warnings",
|
||||
"-o", template, "--print", "after_move:filepath", url],
|
||||
["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites",
|
||||
"--no-warnings", "-o", template,
|
||||
"--print", "after_move:filepath", url],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
||||
if filepath and Path(filepath).is_file():
|
||||
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
|
||||
except Exception:
|
||||
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()
|
||||
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
|
||||
dl_path = await loop.run_in_executor(
|
||||
None, _download_track, track.url, tid,
|
||||
None, _download_track, track.url, tid, track.title,
|
||||
)
|
||||
if dl_path:
|
||||
track.local_path = dl_path
|
||||
@@ -440,6 +470,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
seek=cur_seek,
|
||||
progress=progress,
|
||||
fade_step=lambda: ps.get("fade_step"),
|
||||
fade_in=True,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
elapsed = cur_seek + progress[0] * 0.02
|
||||
@@ -466,6 +497,10 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
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():
|
||||
duck_task.cancel()
|
||||
ps["current"] = None
|
||||
@@ -493,16 +528,24 @@ def _ensure_loop(bot, *, seek: float = 0.0) -> None:
|
||||
_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."""
|
||||
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)
|
||||
# Compute step from actual current volume so fade always spans `duration`.
|
||||
# At 3% vol: step = 0.03/40 = 0.00075 (still ~0.8s fade).
|
||||
# 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
|
||||
log.debug("music: fading out (vol=%.2f, step=%.5f, duration=%.1fs)",
|
||||
cur_vol, step, duration)
|
||||
await asyncio.sleep(duration)
|
||||
ps["fade_step"] = None
|
||||
if not task.done():
|
||||
@@ -533,14 +576,45 @@ async def cmd_play(bot, message):
|
||||
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !play <url|query>")
|
||||
await bot.reply(message, "Usage: !play <url|query|#id>")
|
||||
return
|
||||
|
||||
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)
|
||||
if is_search:
|
||||
url = f"ytsearch10:{url}"
|
||||
ps = _ps(bot)
|
||||
|
||||
if len(ps["queue"]) >= _MAX_QUEUE:
|
||||
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")
|
||||
return
|
||||
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
|
||||
loop = asyncio.get_running_loop()
|
||||
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["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
|
||||
parts = []
|
||||
title = meta.get("title") or track.title
|
||||
artist = meta.get("artist", "")
|
||||
dur = meta.get("duration", 0)
|
||||
parts.append(_truncate(title))
|
||||
label = _truncate(title)
|
||||
if artist and artist.lower() not in ("na", "unknown", ""):
|
||||
parts[0] += f" -- {artist}"
|
||||
label += f" -- {artist}"
|
||||
if dur > 0:
|
||||
parts[0] += f" ({_fmt_time(dur)})"
|
||||
await bot.reply(message, f"Keeping: {parts[0]}")
|
||||
label += f" ({_fmt_time(dur)})"
|
||||
await bot.reply(message, f"Keeping #{keep_id}: {label}")
|
||||
|
||||
|
||||
@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():
|
||||
f.unlink()
|
||||
count += 1
|
||||
# Clear stored metadata
|
||||
# Clear stored metadata and reset ID counter
|
||||
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)
|
||||
await bot.reply(message, f"Deleted {count} file(s)")
|
||||
return
|
||||
|
||||
files = sorted(_MUSIC_DIR.iterdir()) if _MUSIC_DIR.is_dir() else []
|
||||
files = [f for f in files if f.is_file()]
|
||||
if not files:
|
||||
await bot.reply(message, "No kept files")
|
||||
# Collect kept entries from state
|
||||
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
|
||||
entries.append(meta)
|
||||
|
||||
if not entries:
|
||||
await bot.reply(message, "No kept tracks")
|
||||
return
|
||||
|
||||
lines = [f"Kept files ({len(files)}):"]
|
||||
for f in files:
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
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")
|
||||
entries.sort(key=lambda m: m.get("id", 0))
|
||||
lines = [f"Kept tracks ({len(entries)}):"]
|
||||
for meta in entries:
|
||||
kid = meta.get("id", "?")
|
||||
title = meta.get("title", "")
|
||||
artist = meta.get("artist", "")
|
||||
dur = meta.get("duration", 0)
|
||||
filename = meta.get("filename", "")
|
||||
label = _truncate(title) if title else filename
|
||||
if artist and artist.lower() not in ("na", "unknown", ""):
|
||||
label += f" -- {artist}"
|
||||
if dur > 0:
|
||||
label += f" ({_fmt_time(dur)})"
|
||||
# Show file size if file exists
|
||||
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]"
|
||||
lines.append(f" #{kid} {label}{size}")
|
||||
await bot.long_reply(message, lines, label="kept tracks")
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
@@ -45,9 +45,13 @@ def _strip_html(text: str) -> str:
|
||||
return html.unescape(_TAG_RE.sub("", text))
|
||||
|
||||
|
||||
_URL_RE = re.compile(r'(https?://[^\s<>&]+)')
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape text for Mumble HTML messages."""
|
||||
return html.escape(text, quote=False)
|
||||
"""Escape text for Mumble HTML messages, auto-linking URLs."""
|
||||
escaped = html.escape(text, quote=False)
|
||||
return _URL_RE.sub(r'<a href="\1">\1</a>', escaped)
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
@@ -522,6 +526,7 @@ class MumbleBot:
|
||||
seek: float = 0.0,
|
||||
progress: list | None = None,
|
||||
fade_step=None,
|
||||
fade_in: bool = False,
|
||||
) -> None:
|
||||
"""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
|
||||
callable returning a float or None; when non-None it overrides
|
||||
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:
|
||||
return
|
||||
@@ -561,7 +568,12 @@ class MumbleBot:
|
||||
)
|
||||
|
||||
_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
|
||||
|
||||
frames = 0
|
||||
@@ -593,17 +605,21 @@ class MumbleBot:
|
||||
|
||||
target = _get_vol()
|
||||
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()
|
||||
if fs:
|
||||
step = fs
|
||||
if _cur_vol == target:
|
||||
# Fast path: flat scaling
|
||||
diff = target - _cur_vol
|
||||
if abs(diff) < 0.0001:
|
||||
# Close enough -- flat scaling (no ramp artifacts)
|
||||
if target != 1.0:
|
||||
pcm = _scale_pcm(pcm, target)
|
||||
_cur_vol = target
|
||||
else:
|
||||
# Ramp toward target, clamped to step per frame
|
||||
diff = target - _cur_vol
|
||||
if abs(diff) <= step:
|
||||
next_vol = target
|
||||
elif diff > 0:
|
||||
|
||||
@@ -1134,7 +1134,12 @@ class TestKeepCommand:
|
||||
)
|
||||
ps["current"] = track
|
||||
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 any("Keeping" in r for r in bot.replied)
|
||||
|
||||
@@ -1151,23 +1156,27 @@ class TestKeepCommand:
|
||||
|
||||
|
||||
class TestKeptCommand:
|
||||
def test_kept_empty(self, tmp_path):
|
||||
def test_kept_empty(self):
|
||||
bot = _FakeBot()
|
||||
with patch.object(_mod, "_MUSIC_DIR", tmp_path / "empty"):
|
||||
msg = _Msg(text="!kept")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("No kept files" in r for r in bot.replied)
|
||||
msg = _Msg(text="!kept")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("No kept tracks" 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()
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(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):
|
||||
msg = _Msg(text="!kept")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Kept files" in r for r in bot.replied)
|
||||
assert any("abc123.opus" in r for r in bot.replied)
|
||||
assert any("Kept tracks" 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):
|
||||
bot = _FakeBot()
|
||||
@@ -1175,11 +1184,13 @@ class TestKeptCommand:
|
||||
music_dir.mkdir()
|
||||
(music_dir / "abc123.opus").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):
|
||||
msg = _Msg(text="!kept clear")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Deleted 2 file(s)" in r for r in bot.replied)
|
||||
assert not list(music_dir.iterdir())
|
||||
assert bot.state.get("music", "keep:1") is None
|
||||
|
||||
def test_kept_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
@@ -1533,7 +1544,7 @@ class TestFadeState:
|
||||
|
||||
class TestKeepMetadata:
|
||||
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()
|
||||
ps = _mod._ps(bot)
|
||||
f = tmp_path / "abc123.opus"
|
||||
@@ -1545,15 +1556,19 @@ class TestKeepMetadata:
|
||||
ps["current"] = track
|
||||
msg = _Msg(text="!keep")
|
||||
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))
|
||||
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
|
||||
stored = json.loads(raw)
|
||||
assert stored["title"] == "My Song"
|
||||
assert stored["artist"] == "Artist"
|
||||
assert stored["duration"] == 195.0
|
||||
assert stored["id"] == 1
|
||||
assert any("My Song" 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)
|
||||
@@ -1571,7 +1586,10 @@ class TestKeepMetadata:
|
||||
ps["current"] = track
|
||||
msg = _Msg(text="!keep")
|
||||
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))
|
||||
# Should not contain "NA" as artist
|
||||
assert not any("NA" in r and "--" in r for r in bot.replied)
|
||||
@@ -1584,13 +1602,14 @@ class TestKeepMetadata:
|
||||
|
||||
class TestKeptMetadata:
|
||||
def test_kept_shows_metadata(self, tmp_path):
|
||||
"""!kept displays metadata from bot.state when available."""
|
||||
"""!kept displays metadata from bot.state."""
|
||||
bot = _FakeBot()
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(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,
|
||||
"filename": "abc123.opus", "id": 1,
|
||||
}))
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||
msg = _Msg(text="!kept")
|
||||
@@ -1598,31 +1617,34 @@ class TestKeptMetadata:
|
||||
assert any("Cool Song" 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("#1" in r for r in bot.replied)
|
||||
|
||||
def test_kept_fallback_no_metadata(self, tmp_path):
|
||||
"""!kept falls back to filename when no metadata stored."""
|
||||
def test_kept_fallback_no_title(self):
|
||||
"""!kept falls back to filename when no title in metadata."""
|
||||
bot = _FakeBot()
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "xyz789.webm").write_bytes(b"x" * 1024)
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||
msg = _Msg(text="!kept")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"title": "", "artist": "", "duration": 0,
|
||||
"filename": "xyz789.webm", "id": 1,
|
||||
}))
|
||||
msg = _Msg(text="!kept")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("xyz789.webm" in r for r in bot.replied)
|
||||
|
||||
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()
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "abc123.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:abc123.opus", json.dumps({
|
||||
"title": "Song", "artist": "", "duration": 0,
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"title": "Song", "artist": "", "duration": 0, "id": 1,
|
||||
}))
|
||||
bot.state.set("music", "keep_next_id", "2")
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||
msg = _Msg(text="!kept clear")
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user