From 135a3791e2fe4e41daefb7591c3f3e746251ee6b Mon Sep 17 00:00:00 2001 From: user Date: Mon, 23 Feb 2026 21:56:39 +0100 Subject: [PATCH] feat: add MusicBrainz fallback to !similar and !tags commands 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 --- plugins/lastfm.py | 148 ++++++++++++++++++++++++++++++------------- tests/test_lastfm.py | 73 +++++++++++++++++++-- 2 files changed, 170 insertions(+), 51 deletions(-) diff --git a/plugins/lastfm.py b/plugins/lastfm.py index 94c36cb..f35f738 100644 --- a/plugins/lastfm.py +++ b/plugins/lastfm.py @@ -198,9 +198,6 @@ async def cmd_similar(bot, message): !similar play 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 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}'") diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py index 856b445..1c3ab10 100644 --- a/tests/test_lastfm.py +++ b/tests/test_lastfm.py @@ -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()