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:
@@ -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