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:
user
2026-02-23 23:56:51 +01:00
parent b658053711
commit dd4c6b95b7
5 changed files with 357 additions and 173 deletions

View File

@@ -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: