From 85c74ba10958e3a75d67a3713fd41ad55f31d2f5 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 23:21:50 +0200 Subject: [PATCH] feat: add real MusicBrainz resolver and opt-in live test --- worker/lyra_worker/_musicbrainz.py | 72 +++++++++++++++++++++++++++ worker/requirements.txt | 1 + worker/tests/test_musicbrainz_live.py | 18 +++++++ 3 files changed, 91 insertions(+) create mode 100644 worker/lyra_worker/_musicbrainz.py create mode 100644 worker/tests/test_musicbrainz_live.py diff --git a/worker/lyra_worker/_musicbrainz.py b/worker/lyra_worker/_musicbrainz.py new file mode 100644 index 0000000..293032e --- /dev/null +++ b/worker/lyra_worker/_musicbrainz.py @@ -0,0 +1,72 @@ +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() + + +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", []) + if not groups: + return None + rg = max(groups, key=lambda g: _ratio(album, g.get("title", ""))) + if _ratio(album, rg.get("title", "")) < _MIN_TITLE_RATIO: + return None + + canonical_album = rg.get("title", album) + canonical_artist = artist + credit = rg.get("artist-credit") + if isinstance(credit, list) and credit and isinstance(credit[0], dict): + canonical_artist = (credit[0].get("artist") or {}).get("name", artist) + + year = None + frd = rg.get("first-release-date", "") or "" + if frd[:4].isdigit(): + year = int(frd[:4]) + + track_count = None + total_duration_s = None + 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 + + return MBTarget( + artist=canonical_artist, + album=canonical_album, + track_count=track_count, + total_duration_s=total_duration_s, + year=year, + ) diff --git a/worker/requirements.txt b/worker/requirements.txt index bd020c8..906dc41 100644 --- a/worker/requirements.txt +++ b/worker/requirements.txt @@ -4,3 +4,4 @@ cryptography>=42,<46 yt-dlp>=2024.1 streamrip>=2.0 requests>=2.31 +musicbrainzngs>=0.7.1 diff --git a/worker/tests/test_musicbrainz_live.py b/worker/tests/test_musicbrainz_live.py new file mode 100644 index 0000000..c303636 --- /dev/null +++ b/worker/tests/test_musicbrainz_live.py @@ -0,0 +1,18 @@ +import os + +import pytest + +from lyra_worker._musicbrainz import MusicBrainzResolver + +pytestmark = pytest.mark.skipif( + not os.environ.get("LYRA_LIVE_TESTS"), + reason="live MusicBrainz test; set LYRA_LIVE_TESTS=1 to run", +) + + +def test_live_resolves_continuum(): + target = MusicBrainzResolver(contact="lyra-tests@localhost").resolve("John Mayer", "Continuum") + assert target is not None + assert target.track_count and target.track_count >= 10 # Continuum has 12 tracks + assert "continuum" in target.album.casefold() + assert target.year == 2006