MusicBrainz genres: fall back to release then artist when recording is empty

User report: SoulSync was only pulling MusicBrainz genres from the
recording (track-level) endpoint. Most MB recordings don't carry genres
at the track level — they live on the release (album) or artist. So
the MB tier was contributing nothing to the genre merge for the
overwhelming majority of tracks.

Fix:
- Added `'genres'` to the release-detail `includes` (was missing).
- After release-detail processing, if pp['mb_genres'] is still empty,
  populate from release_detail['genres'] (sorted by count desc).
- If still empty AND artist_mbid is set, fetch artist with
  `includes=['genres']` and use those.

No extra API call when the recording (or release) already had genres —
the artist fetch only fires when both upstream tiers came back empty.

The downstream genre merge in _embed_metadata_genres is unchanged; this
just makes the MB feed into it richer.

Tests: 4 new (recording present, recording empty → release, recording
+ release empty → artist, all empty → []). Full suite 873 passing.
Ruff clean.

Reported by @kcaoyef421 in Discord.
This commit is contained in:
Broque Thomas 2026-04-27 19:31:27 -07:00
parent a8319156ce
commit c121582557
2 changed files with 137 additions and 1 deletions

View file

@ -281,7 +281,7 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti
"MusicBrainz release details",
mb_service.mb_client.get_release,
pp["release_mbid"],
includes=["release-groups", "labels", "media", "artist-credits", "recordings"],
includes=["release-groups", "labels", "media", "artist-credits", "recordings", "genres"],
) or {}
with mb_release_detail_cache_lock:
_bounded_cache_set(mb_release_detail_cache, pp["release_mbid"], release_detail, _MB_RELEASE_DETAIL_CACHE_MAX_ENTRIES)
@ -345,6 +345,31 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti
except (ValueError, TypeError):
pass
# Genre fallback chain: most MusicBrainz recordings don't carry genres at
# the track level, but releases and artists usually do. If the recording
# came back empty, try the release; if that's empty too, fetch the artist
# with `includes=['genres']` and use that.
_release_detail_for_genres = locals().get("release_detail")
if not pp["mb_genres"] and _release_detail_for_genres:
pp["mb_genres"] = [
g["name"] for g in sorted(
_release_detail_for_genres.get("genres", []), key=lambda x: x.get("count", 0), reverse=True,
)
]
if not pp["mb_genres"] and pp.get("artist_mbid"):
artist_detail = _call_source_lookup(
"MusicBrainz artist details",
mb_service.mb_client.get_artist,
pp["artist_mbid"],
includes=["genres"],
)
if artist_detail:
pp["mb_genres"] = [
g["name"] for g in sorted(
artist_detail.get("genres", []), key=lambda x: x.get("count", 0), reverse=True,
)
]
def _process_deezer_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
if cfg.get("deezer.embed_tags", True) is False:

View file

@ -359,6 +359,9 @@ def test_musicbrainz_release_lookup_failure_does_not_poison_cache(monkeypatch):
def get_release(self, mbid, includes=None):
return {}
def get_artist(self, mbid, includes=None):
return {}
class _FakeMBService:
def __init__(self):
self.release_calls = 0
@ -593,3 +596,111 @@ def test_download_cover_art_uses_album_context_image_url(tmp_path, monkeypatch):
cover_path = target_dir / "cover.jpg"
assert cover_path.exists()
assert cover_path.read_bytes() == b"cover-bytes"
# ---------------------------------------------------------------------------
# MusicBrainz genre fallback chain (recording → release → artist)
# ---------------------------------------------------------------------------
def _build_mb_genre_test(monkeypatch, *, recording_genres, release_genres, artist_genres):
"""Helper: assemble a fake MB stack with configurable genres at each tier."""
class _FakeMBClient:
def __init__(self):
self.artist_calls = 0
self.release_calls = 0
def get_recording(self, mbid, includes=None):
return {"isrcs": [], "genres": list(recording_genres)}
def get_release(self, mbid, includes=None):
self.release_calls += 1
return {"genres": list(release_genres), "media": []}
def get_artist(self, mbid, includes=None):
self.artist_calls += 1
return {"genres": list(artist_genres)}
class _FakeMBService:
def __init__(self):
self.mb_client = _FakeMBClient()
def match_recording(self, t, a):
return {"mbid": "rec-mbid"}
def match_artist(self, a):
return {"mbid": "artist-mbid"}
def match_release(self, album, artist):
return {"mbid": "release-mbid"}
service = _FakeMBService()
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"musicbrainz.embed_tags": True}))
monkeypatch.setattr(ms, "mb_release_cache", {})
monkeypatch.setattr(ms, "mb_release_detail_cache", {})
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=service))
pp = {
"id_tags": {}, "track_title": "T", "artist_name": "A", "batch_artist_name": "A",
"metadata": {"album": "Alb"}, "recording_mbid": None, "artist_mbid": None,
"release_mbid": "", "mb_genres": [], "isrc": None,
"deezer_bpm": None, "deezer_isrc": None,
"audiodb_mood": None, "audiodb_style": None, "audiodb_genre": None,
"tidal_isrc": None, "tidal_copyright": None,
"qobuz_isrc": None, "qobuz_copyright": None, "qobuz_label": None,
"lastfm_tags": [], "lastfm_url": None, "genius_url": None, "release_year": None,
}
return pp, service, runtime
def test_mb_genre_recording_used_when_present(monkeypatch):
pp, service, runtime = _build_mb_genre_test(
monkeypatch,
recording_genres=[{"name": "Rock", "count": 5}],
release_genres=[{"name": "Pop", "count": 10}],
artist_genres=[{"name": "Jazz", "count": 20}],
)
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
runtime, "T", "A")
assert pp["mb_genres"] == ["Rock"]
# Release/artist genre lookups not consulted because recording had genres
assert service.mb_client.artist_calls == 0
def test_mb_genre_falls_back_to_release_when_recording_empty(monkeypatch):
pp, service, runtime = _build_mb_genre_test(
monkeypatch,
recording_genres=[],
release_genres=[{"name": "Pop", "count": 10}, {"name": "Indie", "count": 3}],
artist_genres=[{"name": "Jazz", "count": 20}],
)
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
runtime, "T", "A")
# Sorted by count desc: Pop (10) before Indie (3)
assert pp["mb_genres"] == ["Pop", "Indie"]
# Artist not consulted because release had genres
assert service.mb_client.artist_calls == 0
def test_mb_genre_falls_back_to_artist_when_recording_and_release_empty(monkeypatch):
pp, service, runtime = _build_mb_genre_test(
monkeypatch,
recording_genres=[],
release_genres=[],
artist_genres=[{"name": "Jazz", "count": 20}, {"name": "Fusion", "count": 5}],
)
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
runtime, "T", "A")
assert pp["mb_genres"] == ["Jazz", "Fusion"]
assert service.mb_client.artist_calls == 1
def test_mb_genre_all_empty_returns_empty(monkeypatch):
pp, service, runtime = _build_mb_genre_test(
monkeypatch,
recording_genres=[],
release_genres=[],
artist_genres=[],
)
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
runtime, "T", "A")
assert pp["mb_genres"] == []