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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}'")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user