Files
Lyra/worker/lyra_worker/_musicbrainz.py
T
Jonathan 06e7f91897 fix(mb): resolve same-named albums to the Album, not the Single
MusicBrainz search returns release-groups tied on title similarity in an
arbitrary order; `max(groups, key=_ratio)` kept the first, so Michael
Jackson's "Off the Wall" resolved to the 2-track *single* release-group
(Off the Wall / Get on the Floor) instead of the 10-track album. Because
plan_track_names labels files purely by position, that short tracklist got
mapped onto the full album — track 2 became "Get on the Floor" instead of
"Rock with You", and the real "Off the Wall"/"Get on the Floor" files kept
their streamrip names, yielding duplicate titles.

Extract selection into a pure `_best_release_group()` that breaks title-
similarity ties toward primary-type == "Album". Similarity still dominates;
album preference only settles ties. Live-verified: Off the Wall now resolves
to the 10-track album with track 2 = "Rock With You".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:49:26 +02:00

97 lines
3.6 KiB
Python

from difflib import SequenceMatcher
from lyra_worker.types import MBTarget
_MIN_TITLE_RATIO = 0.6
def _ratio(a: str, b: str) -> float:
return SequenceMatcher(None, a.strip().casefold(), b.strip().casefold()).ratio()
def _is_album(g: dict) -> bool:
return (g.get("primary-type") or "").casefold() == "album"
def _best_release_group(album: str, groups: list) -> dict | None:
"""Choose the best release-group match for `album`. Highest title similarity wins;
an Album primary-type breaks ties so a famous album (e.g. Michael Jackson's "Off the
Wall") isn't resolved to its same-named single, whose short tracklist would then be
mapped positionally onto the full album's files. Returns None below the title threshold.
Pure — no network I/O."""
if not groups:
return None
best = max(groups, key=lambda g: (_ratio(album, g.get("title", "")), _is_album(g)))
if _ratio(album, best.get("title", "")) < _MIN_TITLE_RATIO:
return None
return best
class MusicBrainzResolver:
"""Real MbResolver using the MusicBrainz webservice via musicbrainzngs.
NOT unit-tested offline; see test_musicbrainz_live.py. musicbrainzngs is
imported lazily so importing/constructing this class stays offline.
"""
def __init__(self, app_name: str = "Lyra", version: str = "0.1", contact: str = "lyra@localhost"):
self._app = app_name
self._version = version
self._contact = contact
def resolve(self, artist: str, album: str) -> MBTarget | None:
import musicbrainzngs
musicbrainzngs.set_useragent(self._app, self._version, self._contact)
res = musicbrainzngs.search_release_groups(query=album, artist=artist, limit=5)
groups = res.get("release-group-list", [])
rg = _best_release_group(album, groups)
if rg is None:
return None
canonical_album = rg.get("title", album)
canonical_artist = artist
artist_mbid = ""
credit = rg.get("artist-credit")
if isinstance(credit, list) and credit and isinstance(credit[0], dict):
_artist = credit[0].get("artist") or {}
canonical_artist = _artist.get("name", artist)
artist_mbid = _artist.get("id", "") or ""
year = None
frd = rg.get("first-release-date", "") or ""
if frd[:4].isdigit():
year = int(frd[:4])
track_count = None
total_duration_s = None
titles: tuple[str, ...] = ()
rgid = rg.get("id")
if rgid:
rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"])
releases = rgfull.get("release-group", {}).get("release-list", [])
if releases:
rel = musicbrainzngs.get_release_by_id(
releases[0]["id"], includes=["recordings"]
).get("release", {})
tracks = []
for medium in rel.get("medium-list", []):
tracks.extend(medium.get("track-list", []))
if tracks:
track_count = len(tracks)
total_ms = sum(int((t.get("recording") or {}).get("length") or 0) for t in tracks)
total_duration_s = total_ms // 1000 if total_ms else None
titles = tuple((t.get("recording") or {}).get("title", "") for t in tracks)
return MBTarget(
artist=canonical_artist,
album=canonical_album,
track_count=track_count,
total_duration_s=total_duration_s,
year=year,
tracklist=titles,
rg_mbid=rg.get("id", "") or "",
artist_mbid=artist_mbid,
)