diff --git a/TASKS.md b/TASKS.md index 8e50e16..261325c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,19 @@ # derp - Tasks -## Current Sprint -- Enhanced Help with FlaskPaste (2026-02-23) +## Current Sprint -- Discovery Playlists (2026-02-23) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | `!similar` default: discover + resolve + play (playlist mode) | +| P0 | [x] | `!similar list` subcommand for display-only (old default) | +| P0 | [x] | `_search_queries()` normalizes Last.fm/MB results to search strings | +| P0 | [x] | `_resolve_playlist()` parallel yt-dlp resolution via ThreadPoolExecutor | +| P1 | [x] | Playback transition: fade out, clear queue, load playlist, fade in | +| P1 | [x] | Fallback to display when music plugin not loaded | +| P1 | [x] | Tests: 11 new cases (81 total in test_lastfm.py, 1949 suite total) | +| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) | + +## Previous Sprint -- Enhanced Help with FlaskPaste (2026-02-23) | Pri | Status | Task | |-----|--------|------| @@ -319,6 +332,7 @@ | Date | Task | |------|------| +| 2026-02-23 | `!similar` discovery playlists (parallel resolve, fade transition, list subcommand) | | 2026-02-23 | Enhanced `!help` with FlaskPaste detail pages (docstrings, grouped reference) | | 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) | | 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) | diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index deae1d4..5f15112 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -613,14 +613,17 @@ Mumble-only: `!play` replies with error on other adapters, others silently no-op ## Music Discovery ``` -!similar # Similar to currently playing track -!similar # Similar artists to named artist -!similar play # Queue a random similar track -!similar play # Queue similar track for named artist +!similar # Discover + play similar to current track +!similar # Discover + play similar to named artist +!similar list # Show similar (display only) +!similar list # Show similar for named artist !tags # Genre tags for current artist !tags # Genre tags for named artist ``` +Default `!similar` builds a playlist: discovers similar artists, resolves +via YouTube in parallel, fades out current, plays the new playlist. +`!similar list` shows results without playing. Uses Last.fm when API key is set; falls back to MusicBrainz automatically. Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var. diff --git a/docs/USAGE.md b/docs/USAGE.md index 8bc0d3f..a90daa7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1791,19 +1791,22 @@ key is configured; falls back to MusicBrainz automatically (no key required). ``` -!similar Similar to currently playing track -!similar Similar artists to named artist -!similar play Queue a random similar track -!similar play Queue a similar track for named artist +!similar Discover + play similar to current track +!similar Discover + play similar to named artist +!similar list Show similar (display only) +!similar list Show similar for named artist !tags Genre tags for currently playing artist !tags Genre tags for named artist ``` +- Default `!similar` builds a discovery playlist: finds similar artists/tracks, + resolves each against YouTube in parallel, fades out current playback, and + starts the new playlist +- `!similar list` shows results without playing (old default behavior) - When an API key is set, Last.fm is tried first for richer results - When no API key is set (or Last.fm returns empty), MusicBrainz is used as a fallback (artist search -> tags -> similar recordings) -- `!similar play` picks a random result and delegates to `!play` - (searches YouTube for the artist + title) +- Without the music plugin loaded, `!similar` falls back to display mode - MusicBrainz rate limit: 1 request/second (handled automatically) Configuration (optional): diff --git a/plugins/lastfm.py b/plugins/lastfm.py index 628cbb4..9c88704 100644 --- a/plugins/lastfm.py +++ b/plugins/lastfm.py @@ -192,30 +192,141 @@ def _fmt_match(m: float | str) -> str: return "" +# -- Playlist helpers -------------------------------------------------------- + + +def _search_queries(similar: list[dict], similar_artists: list[dict], + mb_results: list[dict], limit: int = 10) -> list[str]: + """Normalize discovery results into YouTube search strings. + + Processes track results (``{artist: {name}, name}``), artist results + (``{name}``), and MusicBrainz results (``{artist, title}``) into a + flat list of search query strings, up to *limit*. + """ + queries: list[str] = [] + for t in similar: + a = t.get("artist", {}).get("name", "") + n = t.get("name", "") + q = f"{a} {n}".strip() + if q: + queries.append(q) + for a in similar_artists: + name = a.get("name", "") + if name: + queries.append(name) + for r in mb_results: + q = f"{r.get('artist', '')} {r.get('title', '')}".strip() + if q: + queries.append(q) + return queries[:limit] + + +async def _resolve_playlist(bot, queries: list[str], + requester: str) -> list: + """Resolve search queries to Track objects via yt-dlp in parallel. + + Uses the music plugin's ``_resolve_tracks`` and ``_Track`` to build + a playlist. Returns a list of ``_Track`` objects (empty on failure). + """ + music_mod = bot.registry._modules.get("music") + if not music_mod: + return [] + + loop = asyncio.get_running_loop() + resolve = music_mod._resolve_tracks + Track = music_mod._Track + + pool = _get_yt_pool() + + async def _resolve_one(query: str): + try: + pairs = await loop.run_in_executor( + pool, resolve, f"ytsearch1:{query}", 1, + ) + if pairs: + url, title = pairs[0] + return Track(url=url, title=title, requester=requester) + except Exception: + log.debug("lastfm: resolve failed for %r", query) + return None + + tasks = [_resolve_one(q) for q in queries] + results = await asyncio.gather(*tasks) + return [t for t in results if t is not None] + + +_yt_pool = None + + +def _get_yt_pool(): + """Lazy-init a shared ThreadPoolExecutor for yt-dlp resolution.""" + global _yt_pool + if _yt_pool is None: + from concurrent.futures import ThreadPoolExecutor + _yt_pool = ThreadPoolExecutor(max_workers=4) + return _yt_pool + + +async def _display_results(bot, message, similar: list[dict], + similar_artists: list[dict], + mb_results: list[dict], + artist: str, title: str) -> None: + """Format and display discovery results (list mode).""" + if similar: + 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 + + search_artist = artist or title + if similar_artists: + lines = [f"Similar to {search_artist}:"] + for a in similar_artists[:8]: + name = a.get("name", "?") + match = _fmt_match(a.get("match", "")) + suffix = f" ({match})" if match else "" + lines.append(f" {name}{suffix}") + await bot.long_reply(message, lines, label="similar artists") + return + + if mb_results: + 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 + + await bot.reply(message, f"No similar artists found for '{search_artist}'") + + # -- Commands ---------------------------------------------------------------- -@command("similar", help="Music: !similar [artist|play] -- find similar music") +@command("similar", help="Music: !similar [list] [artist] -- discover & play similar music") async def cmd_similar(bot, message): - """Find similar artists or tracks. + """Discover and play similar music. Usage: - !similar Similar to currently playing track - !similar Similar artists to named artist - !similar play Queue a random similar track - !similar play Queue a similar track for named artist + !similar Discover + play similar to current track + !similar Discover + play similar to named artist + !similar list Show similar (display only) + !similar list Show similar for named artist """ api_key = _get_api_key(bot) parts = message.text.split(None, 2) - # !similar play [artist] - play_mode = len(parts) >= 2 and parts[1].lower() == "play" - if play_mode: + # !similar list [artist] + list_mode = len(parts) >= 2 and parts[1].lower() == "list" + if list_mode: query = parts[2].strip() if len(parts) > 2 else "" else: query = parts[1].strip() if len(parts) > 1 else "" - import asyncio loop = asyncio.get_running_loop() # Resolve artist from query or current track @@ -229,8 +340,8 @@ async def cmd_similar(bot, message): return # -- Last.fm path -- - similar = [] - similar_artists = [] + similar: list[dict] = [] + similar_artists: list[dict] = [] if api_key: # Try track-level similarity first if we have both artist + title if artist and title: @@ -245,7 +356,7 @@ async def cmd_similar(bot, message): ) # -- MusicBrainz fallback -- - mb_results = [] + mb_results: list[dict] = [] if not similar and not similar_artists: search_artist = artist or title try: @@ -267,77 +378,46 @@ async def cmd_similar(bot, message): 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: + # Nothing found at all + if not similar and not similar_artists and not mb_results: search_artist = artist or title - if play_mode: - pick = random.choice(similar_artists[:10]) - pick_name = pick.get("name", "") - if not pick_name: - await bot.reply(message, "No playable result found") - return - message.text = f"!play {pick_name}" - music_mod = bot.registry._modules.get("music") - if music_mod: - await music_mod.cmd_play(bot, message) - return - - lines = [f"Similar to {search_artist}:"] - for a in similar_artists[:8]: - name = a.get("name", "?") - match = _fmt_match(a.get("match", "")) - suffix = f" ({match})" if match else "" - lines.append(f" {name}{suffix}") - await bot.long_reply(message, lines, label="similar artists") + await bot.reply(message, f"No similar artists found for '{search_artist}'") return - # -- 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 - - 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") + # -- List mode (display only) -- + if list_mode: + await _display_results(bot, message, similar, similar_artists, + mb_results, artist, title) return - # Nothing found + # -- Play mode (default): build playlist and transition -- search_artist = artist or title - await bot.reply(message, f"No similar artists found for '{search_artist}'") + queries = _search_queries(similar, similar_artists, mb_results, limit=10) + if not queries: + await bot.reply(message, f"No similar artists found for '{search_artist}'") + return + + music_mod = bot.registry._modules.get("music") + if not music_mod: + # No music plugin -- fall back to display + await _display_results(bot, message, similar, similar_artists, + mb_results, artist, title) + return + + await bot.reply(message, f"Discovering similar to {search_artist}...") + tracks = await _resolve_playlist(bot, queries, message.nick) + if not tracks: + await bot.reply(message, "No playable tracks resolved") + return + + # Transition: fade out current, load new playlist + ps = music_mod._ps(bot) + await music_mod._fade_and_cancel(bot, duration=3.0) + ps["queue"].clear() + ps["current"] = None + ps["queue"] = list(tracks) + music_mod._ensure_loop(bot, fade_in=True) + await bot.reply(message, f"Playing {len(tracks)} similar tracks for {search_artist}") @command("tags", help="Music: !tags [artist] -- show genre tags") @@ -353,7 +433,6 @@ async def cmd_tags(bot, message): parts = message.text.split(None, 1) query = parts[1].strip() if len(parts) > 1 else "" - import asyncio loop = asyncio.get_running_loop() if query: diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py index 193f882..b807edd 100644 --- a/tests/test_lastfm.py +++ b/tests/test_lastfm.py @@ -363,11 +363,54 @@ class TestFmtMatch: # --------------------------------------------------------------------------- +class TestSearchQueries: + def test_track_results(self): + similar = [ + {"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"}, + {"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"}, + ] + result = _mod._search_queries(similar, [], []) + assert result == ["Band A Track X", "Band B Track Y"] + + def test_artist_results(self): + artists = [{"name": "Deftones"}, {"name": "APC"}] + result = _mod._search_queries([], artists, []) + assert result == ["Deftones", "APC"] + + def test_mb_results(self): + mb = [{"artist": "MB Band", "title": "MB Song"}] + result = _mod._search_queries([], [], mb) + assert result == ["MB Band MB Song"] + + def test_mixed_sources(self): + """Track results come first, then artist, then MB.""" + similar = [{"name": "T1", "artist": {"name": "A1"}}] + artists = [{"name": "A2"}] + mb = [{"artist": "MB", "title": "S1"}] + result = _mod._search_queries(similar, artists, mb) + assert result == ["A1 T1", "A2", "MB S1"] + + def test_limit(self): + artists = [{"name": f"Band {i}"} for i in range(20)] + result = _mod._search_queries([], artists, [], limit=5) + assert len(result) == 5 + + def test_skips_empty(self): + similar = [{"name": "", "artist": {"name": ""}}] + artists = [{"name": ""}] + mb = [{"artist": "", "title": ""}] + result = _mod._search_queries(similar, artists, mb) + assert result == [] + + def test_empty_inputs(self): + assert _mod._search_queries([], [], []) == [] + + class TestCmdSimilar: - def test_no_api_key_mb_fallback(self): - """No API key falls back to MusicBrainz for similar results.""" + def test_no_api_key_mb_list_fallback(self): + """No API key + list mode falls back to MusicBrainz for results.""" bot = _FakeBot(api_key="") - msg = _Msg(text="!similar Tool") + msg = _Msg(text="!similar list Tool") mb_picks = [{"artist": "MB Artist", "title": "MB Song"}] with patch.dict("os.environ", {}, clear=True), \ patch("plugins._musicbrainz.mb_search_artist", @@ -390,40 +433,16 @@ class TestCmdSimilar: 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() msg = _Msg(text="!similar") asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Nothing playing" in r for r in bot.replied) - def test_artist_query_shows_similar(self): + def test_list_artist_shows_similar(self): + """!similar list shows similar artists (display only).""" bot = _FakeBot() - msg = _Msg(text="!similar Tool") + msg = _Msg(text="!similar list Tool") with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]): @@ -431,26 +450,26 @@ class TestCmdSimilar: assert any("Similar to Tool" in r for r in bot.replied) assert any("Artist B" in r for r in bot.replied) - def test_track_level_similarity(self): - """When current track has artist + title, tries track similarity first.""" + def test_list_track_level(self): + """!similar list with track results shows track similarity.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } - msg = _Msg(text="!similar") + msg = _Msg(text="!similar list") tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"] with patch.object(_mod, "_get_similar_tracks", return_value=tracks): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Tool - Lateralus" in r for r in bot.replied) assert any("Track X" in r for r in bot.replied) - def test_falls_back_to_artist(self): - """Falls back to artist similarity when no track results.""" + def test_list_falls_back_to_artist(self): + """!similar list falls back to artist similarity when no track results.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Tool - Lateralus"), } - msg = _Msg(text="!similar") + msg = _Msg(text="!similar list") artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): @@ -465,71 +484,137 @@ class TestCmdSimilar: asyncio.run(_mod.cmd_similar(bot, msg)) assert any("No similar artists" in r for r in bot.replied) - def test_play_mode_artist(self): - """!similar play delegates to music cmd_play.""" + def test_list_match_score_displayed(self): bot = _FakeBot() - msg = _Msg(text="!similar play Tool") - artists = [{"name": "Deftones", "match": "0.8"}] - 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.object(_mod, "_get_similar_tracks", return_value=[]): - with patch.object(_mod, "_get_similar_artists", return_value=artists): - asyncio.run(_mod.cmd_similar(bot, msg)) - assert len(play_called) == 1 - assert "Deftones" in play_called[0] - - def test_play_mode_track(self): - """!similar play with track-level results delegates to cmd_play.""" - bot = _FakeBot() - bot._pstate["music"] = { - "current": _FakeTrack(title="Tool - Lateralus"), - } - msg = _Msg(text="!similar play") - tracks = [{"name": "Schism", "artist": {"name": "Tool"}, "match": "0.9"}] - 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.object(_mod, "_get_similar_tracks", return_value=tracks): - asyncio.run(_mod.cmd_similar(bot, msg)) - assert len(play_called) == 1 - assert "Tool" in play_called[0] - assert "Schism" in play_called[0] - - def test_match_score_displayed(self): - bot = _FakeBot() - msg = _Msg(text="!similar Tool") + msg = _Msg(text="!similar list Tool") artists = [{"name": "Deftones", "match": "0.85"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("85%" in r for r in bot.replied) - def test_current_track_no_separator(self): + def test_list_current_track_no_separator(self): """Title without separator uses whole title as search artist.""" bot = _FakeBot() bot._pstate["music"] = { "current": _FakeTrack(title="Lateralus"), } - msg = _Msg(text="!similar") + msg = _Msg(text="!similar list") artists = [{"name": "APC", "match": "0.7"}] with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_artists", return_value=artists): asyncio.run(_mod.cmd_similar(bot, msg)) assert any("Similar to Lateralus" in r for r in bot.replied) + def test_builds_playlist(self): + """Default !similar builds playlist and starts playback.""" + bot = _FakeBot() + msg = _Msg(text="!similar Tool") + artists = [{"name": "Deftones", "match": "0.8"}] + fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")] + + music_mod = MagicMock() + music_mod._ps.return_value = { + "queue": [], "current": None, "task": None, + } + music_mod._fade_and_cancel = AsyncMock() + music_mod._ensure_loop = MagicMock() + bot.registry._modules["music"] = music_mod + + with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ + patch.object(_mod, "_get_similar_artists", return_value=artists), \ + patch.object(_mod, "_resolve_playlist", + return_value=fake_tracks): + asyncio.run(_mod.cmd_similar(bot, msg)) + + music_mod._fade_and_cancel.assert_called_once() + music_mod._ensure_loop.assert_called_once() + ps = music_mod._ps(bot) + assert ps["queue"] == fake_tracks + assert any("Playing 1 similar" in r for r in bot.replied) + + def test_builds_playlist_from_current_track(self): + """!similar with no args discovers from currently playing track.""" + bot = _FakeBot() + bot._pstate["music"] = { + "current": _FakeTrack(title="Tool - Lateralus"), + } + msg = _Msg(text="!similar") + tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"] + fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")] + + music_mod = MagicMock() + music_mod._ps.return_value = { + "queue": [], "current": None, "task": None, + } + music_mod._fade_and_cancel = AsyncMock() + music_mod._ensure_loop = MagicMock() + bot.registry._modules["music"] = music_mod + + with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \ + patch.object(_mod, "_resolve_playlist", + return_value=fake_tracks): + asyncio.run(_mod.cmd_similar(bot, msg)) + + assert any("Playing 1 similar" in r for r in bot.replied) + assert any("Tool" in r for r in bot.replied) + + def test_no_music_mod_falls_back_to_display(self): + """Without music plugin, !similar falls back to display mode.""" + bot = _FakeBot() + msg = _Msg(text="!similar Tool") + artists = [{"name": "Deftones", "match": "0.8"}] + with patch.object(_mod, "_get_similar_tracks", return_value=[]): + with patch.object(_mod, "_get_similar_artists", return_value=artists): + asyncio.run(_mod.cmd_similar(bot, msg)) + # Falls back to display since no music module registered + assert any("Similar to Tool" in r for r in bot.replied) + assert any("Deftones" in r for r in bot.replied) + + def test_no_playable_tracks_resolved(self): + """Shows error when resolution returns empty.""" + bot = _FakeBot() + msg = _Msg(text="!similar Tool") + artists = [{"name": "Deftones", "match": "0.8"}] + + music_mod = MagicMock() + music_mod._ps.return_value = {"queue": [], "current": None} + bot.registry._modules["music"] = music_mod + + with patch.object(_mod, "_get_similar_tracks", return_value=[]), \ + patch.object(_mod, "_get_similar_artists", return_value=artists), \ + patch.object(_mod, "_resolve_playlist", + return_value=[]): + asyncio.run(_mod.cmd_similar(bot, msg)) + assert any("No playable tracks" in r for r in bot.replied) + + def test_mb_builds_playlist(self): + """MB fallback results build playlist in play mode.""" + bot = _FakeBot(api_key="") + msg = _Msg(text="!similar Tool") + mb_picks = [{"artist": "MB Band", "title": "MB Track"}] + fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")] + + music_mod = MagicMock() + music_mod._ps.return_value = { + "queue": [], "current": None, "task": None, + } + music_mod._fade_and_cancel = AsyncMock() + music_mod._ensure_loop = MagicMock() + 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), \ + patch.object(_mod, "_resolve_playlist", + return_value=fake_tracks): + asyncio.run(_mod.cmd_similar(bot, msg)) + assert any("Playing 1 similar" in r for r in bot.replied) + # --------------------------------------------------------------------------- # TestCmdTags