feat: add MusicBrainz fallback to !similar and !tags commands
Some checks failed
CI / gitleaks (push) Failing after 2s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped

Remove early return on missing Last.fm API key. Both commands now
fall back to MusicBrainz (mb_search_artist -> mb_artist_tags ->
mb_find_similar_recordings) when no API key is configured or when
Last.fm returns empty results. Same pattern used by discover_similar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-23 21:56:39 +01:00
parent a87f75adf1
commit 135a3791e2
2 changed files with 170 additions and 51 deletions

View File

@@ -198,9 +198,6 @@ async def cmd_similar(bot, message):
!similar play <artist> Queue a similar track for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 2)
# !similar play [artist]
@@ -223,38 +220,86 @@ async def cmd_similar(bot, message):
await bot.reply(message, "Nothing playing and no artist given")
return
# Try track-level similarity first if we have both artist + title
# -- Last.fm path --
similar = []
if artist and title:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title,
)
similar_artists = []
if api_key:
# Try track-level similarity first if we have both artist + title
if artist and title:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title,
)
# Fall back to artist-level similarity
if not similar:
search_artist = artist or title
similar_artists = await loop.run_in_executor(
None, _get_similar_artists, api_key, search_artist,
)
# Fall back to artist-level similarity
if not similar:
# -- MusicBrainz fallback --
mb_results = []
if not similar and not similar_artists:
search_artist = artist or title
similar_artists = await loop.run_in_executor(
None, _get_similar_artists, api_key, search_artist,
)
if not similar_artists:
await bot.reply(message, f"No similar artists found for '{search_artist}'")
try:
from plugins._musicbrainz import (
mb_artist_tags,
mb_find_similar_recordings,
mb_search_artist,
)
mbid = await loop.run_in_executor(
None, mb_search_artist, search_artist,
)
if mbid:
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
if tags:
mb_results = await loop.run_in_executor(
None, mb_find_similar_recordings, search_artist,
tags, 20,
)
except Exception:
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
# -- Track-level results (Last.fm) --
if similar:
if play_mode:
pick = random.choice(similar[:10])
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
search = f"{pick_artist} {pick_title}".strip()
if not search:
await bot.reply(message, "No playable result found")
return
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
lines = [f"Similar to {artist} - {title}:"]
for t in similar[:8]:
t_artist = t.get("artist", {}).get("name", "")
t_name = t.get("name", "?")
match = _fmt_match(t.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {t_artist} - {t_name}{suffix}")
await bot.long_reply(message, lines, label="similar tracks")
return
# -- Artist-level results (Last.fm) --
if similar_artists:
search_artist = artist or title
if play_mode:
# Pick a random similar artist and search YouTube
pick = random.choice(similar_artists[:10])
pick_name = pick.get("name", "")
if not pick_name:
await bot.reply(message, "No playable result found")
return
# Inject a !play command with a YouTube search
message.text = f"!play {pick_name}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
# Display similar artists
lines = [f"Similar to {search_artist}:"]
for a in similar_artists[:8]:
name = a.get("name", "?")
@@ -264,30 +309,27 @@ async def cmd_similar(bot, message):
await bot.long_reply(message, lines, label="similar artists")
return
# Track-level results
if play_mode:
pick = random.choice(similar[:10])
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
search = f"{pick_artist} {pick_title}".strip()
if not search:
await bot.reply(message, "No playable result found")
# -- MusicBrainz results --
if mb_results:
search_artist = artist or title
if play_mode:
pick = random.choice(mb_results[:10])
search = f"{pick['artist']} {pick['title']}".strip()
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
lines = [f"Similar to {search_artist}:"]
for r in mb_results[:8]:
lines.append(f" {r['artist']} - {r['title']}")
await bot.long_reply(message, lines, label="similar tracks")
return
# Display similar tracks
lines = [f"Similar to {artist} - {title}:"]
for t in similar[:8]:
t_artist = t.get("artist", {}).get("name", "")
t_name = t.get("name", "?")
match = _fmt_match(t.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {t_artist} - {t_name}{suffix}")
await bot.long_reply(message, lines, label="similar tracks")
# Nothing found
search_artist = artist or title
await bot.reply(message, f"No similar artists found for '{search_artist}'")
@command("tags", help="Music: !tags [artist] -- show genre tags")
@@ -299,9 +341,6 @@ async def cmd_tags(bot, message):
!tags <artist> Tags for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 1)
query = parts[1].strip() if len(parts) > 1 else ""
@@ -318,9 +357,28 @@ async def cmd_tags(bot, message):
await bot.reply(message, "Nothing playing and no artist given")
return
tags = await loop.run_in_executor(
None, _get_top_tags, api_key, artist,
)
# -- Last.fm path --
tags = []
if api_key:
tags = await loop.run_in_executor(
None, _get_top_tags, api_key, artist,
)
# -- MusicBrainz fallback --
if not tags:
try:
from plugins._musicbrainz import mb_artist_tags, mb_search_artist
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
if mbid:
mb_tags = await loop.run_in_executor(
None, mb_artist_tags, mbid,
)
if mb_tags:
await bot.reply(message, f"{artist}: {', '.join(mb_tags)}")
return
except Exception:
log.warning("lastfm: MusicBrainz tag fallback failed",
exc_info=True)
if not tags:
await bot.reply(message, f"No tags found for '{artist}'")

View File

@@ -339,12 +339,56 @@ class TestFmtMatch:
class TestCmdSimilar:
def test_no_api_key(self):
def test_no_api_key_mb_fallback(self):
"""No API key falls back to MusicBrainz for similar results."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar Tool")
with patch.dict("os.environ", {}, clear=True):
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value="mbid-123"), \
patch("plugins._musicbrainz.mb_artist_tags",
return_value=["rock", "metal"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("not configured" in r for r in bot.replied)
assert any("Similar to Tool" in r for r in bot.replied)
assert any("MB Artist" in r for r in bot.replied)
def test_no_api_key_mb_no_results(self):
"""No API key + MusicBrainz returns nothing shows 'no similar'."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar Tool")
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value=None):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No similar artists" in r for r in bot.replied)
def test_no_api_key_play_mode(self):
"""No API key + play mode delegates to cmd_play via MB results."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar play Tool")
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
play_called = []
async def fake_play(b, m):
play_called.append(m.text)
music_mod = MagicMock()
music_mod.cmd_play = fake_play
bot.registry._modules["music"] = music_mod
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value="mbid-123"), \
patch("plugins._musicbrainz.mb_artist_tags",
return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert len(play_called) == 1
assert "MB Band" in play_called[0]
def test_no_artist_nothing_playing(self):
bot = _FakeBot()
@@ -468,12 +512,29 @@ class TestCmdSimilar:
class TestCmdTags:
def test_no_api_key(self):
def test_no_api_key_mb_fallback(self):
"""No API key falls back to MusicBrainz for tags."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!tags Tool")
with patch.dict("os.environ", {}, clear=True):
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value="mbid-123"), \
patch("plugins._musicbrainz.mb_artist_tags",
return_value=["rock", "progressive metal", "art rock"]):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("not configured" in r for r in bot.replied)
assert any("Tool:" in r for r in bot.replied)
assert any("rock" in r for r in bot.replied)
assert any("progressive metal" in r for r in bot.replied)
def test_no_api_key_mb_no_results(self):
"""No API key + MusicBrainz returns nothing shows 'no tags'."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!tags Obscure")
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value=None):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("No tags found" in r for r in bot.replied)
def test_no_artist_nothing_playing(self):
bot = _FakeBot()