feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz

Every Nth autoplay pick (configurable via discover_ratio), query Last.fm
for similar tracks. When Last.fm has no key or returns nothing, fall back
to MusicBrainz tag-based recording search (no API key needed). Discovered
tracks are resolved via yt-dlp and deduplicated within the session. If
discovery fails, the kept-deck shuffle continues as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-23 21:19:41 +01:00
parent 56f6b9822f
commit da9ed51c74
6 changed files with 933 additions and 17 deletions

118
plugins/_musicbrainz.py Normal file
View File

@@ -0,0 +1,118 @@
"""MusicBrainz API helper for music discovery fallback.
Private module (underscore prefix) -- plugin loader skips it.
All functions are blocking; callers should run them in an executor.
"""
from __future__ import annotations
import json
import logging
import time
from urllib.request import Request
log = logging.getLogger(__name__)
_BASE = "https://musicbrainz.org/ws/2"
_UA = "derp-bot/2.0.0 (https://git.mymx.me/username/derp)"
# Rate limit: MusicBrainz requires max 1 request/second.
# We use 1.1s between calls to stay well within limits.
_RATE_INTERVAL = 1.1
_last_request: float = 0.0
def _mb_request(path: str, params: dict | None = None) -> dict:
"""Rate-limited GET to MusicBrainz API. Blocking."""
global _last_request
from derp.http import urlopen
elapsed = time.monotonic() - _last_request
if elapsed < _RATE_INTERVAL:
time.sleep(_RATE_INTERVAL - elapsed)
qs = "&".join(f"{k}={v}" for k, v in (params or {}).items())
url = f"{_BASE}/{path}?fmt=json&{qs}" if qs else f"{_BASE}/{path}?fmt=json"
req = Request(url, headers={"User-Agent": _UA})
try:
resp = urlopen(req, timeout=10, proxy=False)
_last_request = time.monotonic()
return json.loads(resp.read().decode())
except Exception:
_last_request = time.monotonic()
log.warning("musicbrainz: request failed: %s", path, exc_info=True)
return {}
def mb_search_artist(name: str) -> str | None:
"""Search for an artist by name, return MBID or None."""
from urllib.parse import quote
data = _mb_request("artist", {"query": quote(name), "limit": "1"})
artists = data.get("artists", [])
if not artists:
return None
# Require a reasonable score to avoid false matches
score = artists[0].get("score", 0)
if score < 50:
return None
return artists[0].get("id")
def mb_artist_tags(mbid: str) -> list[str]:
"""Fetch top 5 tags for an artist by MBID."""
data = _mb_request(f"artist/{mbid}", {"inc": "tags"})
tags = data.get("tags", [])
if not tags:
return []
# Sort by count descending, take top 5
sorted_tags = sorted(tags, key=lambda t: t.get("count", 0), reverse=True)
return [t["name"] for t in sorted_tags[:5] if t.get("name")]
def mb_find_similar_recordings(artist: str, tags: list[str],
limit: int = 10) -> list[dict]:
"""Find recordings by other artists sharing top tags.
Searches MusicBrainz for recordings tagged with the top 2 tags,
excluding the original artist. Returns [{"artist": str, "title": str}].
"""
from urllib.parse import quote
if not tags:
return []
# Use top 2 tags for the query
tag_query = " AND ".join(f'tag:"{t}"' for t in tags[:2])
query = f'({tag_query}) AND NOT artist:"{artist}"'
data = _mb_request("recording", {
"query": quote(query),
"limit": str(limit),
})
recordings = data.get("recordings", [])
if not recordings:
return []
seen = set()
results = []
for rec in recordings:
title = rec.get("title", "")
credits = rec.get("artist-credit", [])
if not credits or not title:
continue
rec_artist = credits[0].get("name", "") if credits else ""
if not rec_artist:
continue
# Skip the original artist (case-insensitive)
if rec_artist.lower() == artist.lower():
continue
# Deduplicate by artist+title
key = f"{rec_artist.lower()}:{title.lower()}"
if key in seen:
continue
seen.add(key)
results.append({"artist": rec_artist, "title": title})
return results

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
@@ -91,6 +92,19 @@ def _search_track(api_key: str, query: str,
# -- Metadata extraction -----------------------------------------------------
def _parse_title(raw_title: str) -> tuple[str, str]:
"""Split a raw track title into (artist, title).
Tries common separators: `` - ``, `` -- ``, `` | ``, `` ~ ``.
Returns ``("", raw_title)`` if no separator is found.
"""
for sep in (" - ", " -- ", " | ", " ~ "):
if sep in raw_title:
parts = raw_title.split(sep, 1)
return (parts[0].strip(), parts[1].strip())
return ("", raw_title)
def _current_meta(bot) -> tuple[str, str]:
"""Extract artist and title from the currently playing track.
@@ -103,15 +117,60 @@ def _current_meta(bot) -> tuple[str, str]:
if current is None:
return ("", "")
raw_title = current.title or ""
return _parse_title(raw_title)
# Try common "Artist - Title" patterns
for sep in (" - ", " -- ", " | ", " ~ "):
if sep in raw_title:
parts = raw_title.split(sep, 1)
return (parts[0].strip(), parts[1].strip())
# No separator -- treat whole thing as a search query
return ("", raw_title)
# -- Discovery orchestrator --------------------------------------------------
async def discover_similar(bot, last_track_title: str) -> tuple[str, str] | None:
"""Find a similar track via Last.fm or MusicBrainz fallback.
Returns ``(artist, title)`` or ``None`` if nothing found.
"""
artist, title = _parse_title(last_track_title)
loop = asyncio.get_running_loop()
# -- Last.fm path --
api_key = _get_api_key(bot)
if api_key and artist:
try:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title, 20,
)
if similar:
pick = random.choice(similar)
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
if pick_artist and pick_title:
return (pick_artist, pick_title)
except Exception:
log.warning("lastfm: discover via Last.fm failed", exc_info=True)
# -- MusicBrainz fallback --
if 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, artist)
if mbid:
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
if tags:
picks = await loop.run_in_executor(
None, mb_find_similar_recordings, artist, tags, 20,
)
if picks:
pick = random.choice(picks)
return (pick["artist"], pick["title"])
except Exception:
log.warning("lastfm: discover via MusicBrainz failed",
exc_info=True)
return None
# -- Formatting --------------------------------------------------------------

View File

@@ -58,6 +58,8 @@ def _ps(bot):
"history": [],
"autoplay": cfg.get("autoplay", True),
"autoplay_cooldown": cfg.get("autoplay_cooldown", 30),
"discover": cfg.get("discover", True),
"discover_ratio": cfg.get("discover_ratio", 3),
"announce": cfg.get("announce", False),
"paused": None,
"_watcher_task": None,
@@ -575,17 +577,64 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
seek_req = [None]
ps["seek_req"] = seek_req
_autoplay_pool: list[_Track] = [] # shuffled deck, refilled each cycle
_discover_seen: set[str] = set() # "artist:title" dedup within session
_autoplay_count: int = 0 # autoplay picks since loop start
try:
while ps["queue"] or ps.get("autoplay"):
# Autoplay: cooldown + silence wait, then pick next from shuffled deck
if not ps["queue"]:
if not _autoplay_pool:
kept = _load_kept_tracks(bot)
if not kept:
break
random.shuffle(kept)
_autoplay_pool = kept
log.info("music: autoplay shuffled %d kept tracks", len(kept))
_autoplay_count += 1
# -- Discovery attempt on every Nth autoplay pick --
discovered = False
ratio = ps.get("discover_ratio", 3)
if (ps.get("discover") and ratio > 0
and _autoplay_count % ratio == 0
and ps["history"]):
last = ps["history"][-1]
try:
lfm = bot.registry._modules.get("lastfm")
if lfm and hasattr(lfm, "discover_similar"):
pair = await lfm.discover_similar(bot, last.title)
if pair:
a, t = pair
key = f"{a.lower()}:{t.lower()}"
if key not in _discover_seen:
_discover_seen.add(key)
loop = asyncio.get_running_loop()
res = await loop.run_in_executor(
None, _resolve_tracks,
f"{a} {t}", 1,
)
if res:
discovered = True
pick = _Track(
url=res[0][0], title=res[0][1],
requester="discover",
)
log.info(
"music: discovered '%s' "
"similar to '%s'",
pick.title, last.title,
)
except Exception:
log.warning(
"music: discovery failed, using kept deck",
exc_info=True,
)
# -- Kept-deck fallback --
if not discovered:
if not _autoplay_pool:
kept = _load_kept_tracks(bot)
if not kept:
break
random.shuffle(kept)
_autoplay_pool = kept
log.info("music: autoplay shuffled %d kept tracks",
len(kept))
pick = _autoplay_pool.pop(0)
cooldown = ps.get("autoplay_cooldown", 30)
log.info("music: autoplay cooldown %ds before next track",
cooldown)
@@ -602,9 +651,8 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
# Re-check: someone may have queued something or stopped
if ps["queue"]:
continue
pick = _autoplay_pool.pop(0)
ps["queue"].append(pick)
log.info("music: autoplay picked '%s' (%d remaining)",
log.info("music: autoplay queued '%s' (%d pool remaining)",
pick.title, len(_autoplay_pool))
track = ps["queue"].pop(0)
ps["current"] = track