feat: rework !similar to build and play discovery playlists
Default !similar now discovers similar artists/tracks, resolves each against YouTube in parallel via ThreadPoolExecutor, fades out current playback, and starts the new playlist. Old display behavior moves to !similar list subcommand. New helpers: _search_queries() normalizes Last.fm/MB results into search strings, _resolve_playlist() resolves queries to _Track objects in parallel. Falls back to display mode when music plugin not loaded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
TASKS.md
16
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) |
|
||||
|
||||
@@ -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 <artist> # Similar artists to named artist
|
||||
!similar play # Queue a random similar track
|
||||
!similar play <artist># Queue similar track for named artist
|
||||
!similar # Discover + play similar to current track
|
||||
!similar <artist> # Discover + play similar to named artist
|
||||
!similar list # Show similar (display only)
|
||||
!similar list <artist># Show similar for named artist
|
||||
!tags # Genre tags for current artist
|
||||
!tags <artist> # 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.
|
||||
|
||||
|
||||
@@ -1791,19 +1791,22 @@ key is configured; falls back to MusicBrainz automatically (no key
|
||||
required).
|
||||
|
||||
```
|
||||
!similar Similar to currently playing track
|
||||
!similar <artist> Similar artists to named artist
|
||||
!similar play Queue a random similar track
|
||||
!similar play <artist> Queue a similar track for named artist
|
||||
!similar Discover + play similar to current track
|
||||
!similar <artist> Discover + play similar to named artist
|
||||
!similar list Show similar (display only)
|
||||
!similar list <artist> Show similar for named artist
|
||||
!tags Genre tags for currently playing artist
|
||||
!tags <artist> 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):
|
||||
|
||||
@@ -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 <artist> Similar artists to named artist
|
||||
!similar play Queue a random similar track
|
||||
!similar play <artist> Queue a similar track for named artist
|
||||
!similar Discover + play similar to current track
|
||||
!similar <artist> Discover + play similar to named artist
|
||||
!similar list Show similar (display only)
|
||||
!similar list <artist> 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:
|
||||
|
||||
@@ -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 <artist> 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
|
||||
|
||||
Reference in New Issue
Block a user