feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm for similar tracks. When Last.fm has no key or returns nothing, fall back to MusicBrainz tag-based recording search (no API key needed). Discovered tracks are resolved via yt-dlp and deduplicated within the session. If discovery fails, the kept-deck shuffle continues as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
@@ -521,3 +521,142 @@ class TestCmdTags:
|
||||
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("Lateralus:" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestParseTitle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseTitle:
|
||||
def test_dash_separator(self):
|
||||
assert _mod._parse_title("Tool - Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_double_dash(self):
|
||||
assert _mod._parse_title("Tool -- Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_pipe_separator(self):
|
||||
assert _mod._parse_title("Tool | Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_tilde_separator(self):
|
||||
assert _mod._parse_title("Tool ~ Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_no_separator(self):
|
||||
assert _mod._parse_title("Lateralus") == ("", "Lateralus")
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _mod._parse_title("") == ("", "")
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _mod._parse_title(" Tool - Lateralus ") == ("Tool", "Lateralus")
|
||||
|
||||
def test_first_separator_wins(self):
|
||||
"""Only the first matching separator is used."""
|
||||
assert _mod._parse_title("A - B - C") == ("A", "B - C")
|
||||
|
||||
def test_dash_priority_over_pipe(self):
|
||||
"""Dash separator is tried before pipe."""
|
||||
assert _mod._parse_title("A - B | C") == ("A", "B | C")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestDiscoverSimilar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoverSimilar:
|
||||
def test_lastfm_path(self):
|
||||
"""Returns Last.fm result when API key + results available."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
tracks = [{"name": "Found", "artist": {"name": "Band"}, "match": "0.9"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("Band", "Found")
|
||||
|
||||
def test_lastfm_empty_falls_to_musicbrainz(self):
|
||||
"""Falls back to MusicBrainz when Last.fm returns nothing."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB Artist", "MB Song")
|
||||
|
||||
def test_no_api_key_uses_musicbrainz(self):
|
||||
"""Skips Last.fm when no API key, goes straight to MusicBrainz."""
|
||||
bot = _FakeBot(api_key="")
|
||||
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB Band", "MB Track")
|
||||
|
||||
def test_both_fail_returns_none(self):
|
||||
"""Returns None when both Last.fm and MusicBrainz fail."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value=None):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_artist_returns_none(self):
|
||||
"""Returns None when title has no artist component."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_musicbrainz_import_error_handled(self):
|
||||
"""Gracefully handles import error for _musicbrainz module."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.dict("sys.modules", {"plugins._musicbrainz": None}):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_lastfm_exception_falls_to_musicbrainz(self):
|
||||
"""Last.fm exception triggers MusicBrainz fallback."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
mb_picks = [{"artist": "Fallback", "title": "Song"}]
|
||||
with patch.object(_mod, "_get_similar_tracks",
|
||||
side_effect=Exception("API down")), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("Fallback", "Song")
|
||||
|
||||
def test_lastfm_pick_missing_name_falls_to_musicbrainz(self):
|
||||
"""Falls to MB when Last.fm result has empty artist/title."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
tracks = [{"name": "", "artist": {"name": ""}, "match": "0.9"}]
|
||||
mb_picks = [{"artist": "MB", "title": "Track"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB", "Track")
|
||||
|
||||
Reference in New Issue
Block a user