soulsync/tests/metadata/test_metadata_enrichment.py
Broque Thomas 4b15fe0b75 Fix album MBID inconsistency: detector + persistent release-MBID cache
Discord report (Samuel [KC]): tracks of the same album sometimes carry
different MUSICBRAINZ_ALBUMID tags, which causes Navidrome (and other
media servers grouping by album MBID) to split the album into multiple
entries. Two-part fix — one for existing libraries, one for the root
cause that lets new imports drift.

Part 1 — Detector + fix action (catches existing dissenters):

`core/repair_jobs/mbid_mismatch_detector.py`:
- New helpers: `_read_album_mbid_from_file` and
  `_write_album_mbid_to_file` use the Picard-standard tag conventions
  (`TXXX:MusicBrainz Album Id` for MP3, `MUSICBRAINZ_ALBUMID` for
  FLAC/OGG, `----:com.apple.iTunes:MusicBrainz Album Id` for MP4).
- New scan phase `_scan_album_mbid_consistency` runs after the
  existing track-MBID scan: groups tracks by DB `album_id`, reads
  each track's embedded album MBID, finds the consensus
  (most-common) MBID via `Counter`, flags dissenters. Tracks without
  an album MBID at all are skipped (they don't break Navidrome —
  only an explicit MBID disagreement does). Albums where MBIDs are
  perfectly tied (no clear consensus) are skipped too — surface as
  a manual decision instead of fixing toward a 1/N tie.
- New finding type `album_mbid_mismatch` carries `consensus_mbid`,
  `wrong_mbid`, `consensus_count`, `total_tracks_with_mbid`, and a
  human-readable reason string.

`core/repair_worker.py`:
- Added `'album_mbid_mismatch': self._fix_album_mbid_mismatch` to the
  fix dispatch dict and to the `fixable_types` tuple so auto-fix +
  bulk-fix paths pick it up.
- New `_fix_album_mbid_mismatch` method reads `consensus_mbid` from
  finding details, resolves the dissenter's file path via the shared
  library resolver, calls `_write_album_mbid_to_file` to rewrite the
  tag in place. Doesn't touch the album's other tracks (they're
  already in agreement).

Part 2 — Root cause fix (prevents new SoulSync imports from drifting):

The original in-memory `mb_release_cache` in `core/metadata/source.py`
maps `(normalized_album, artist) -> release_mbid` so per-track
enrichment of the same album hits the cache and writes the same
MUSICBRAINZ_ALBUMID to every track. That cache is bounded (4096
entries) and in-process — so cache eviction (when other albums are
processed in between) and server restart can BOTH cause
inconsistency. Per-track album-name variation (e.g. some tracks
tagged `"Album"`, others tagged `"Album (Deluxe)"`) and per-track
artist variation (features) make it worse.

`core/metadata/album_mbid_cache.py` (new module):
- DB-backed `lookup(normalized_album, artist) -> release_mbid` and
  `record(...)` functions. Same key shape as the in-memory cache.
- Strict additive design: every public function is wrapped in
  try/except and degrades to None / no-op on ANY database error.
  The existing in-memory cache + MusicBrainz lookup remains the
  authoritative fallback. If this module breaks, downloads continue
  exactly as they would today.

`database/music_database.py`:
- New `mb_album_release_cache` table with composite primary key
  `(normalized_album_key, artist_key)`. Reverse-lookup index on
  `release_mbid` for future debug tooling. Created via the existing
  `CREATE TABLE IF NOT EXISTS` migration pattern — idempotent, no
  schema version bump needed.

`core/metadata/source.py`:
- Surgical change inside the existing `embed_source_ids`
  in-memory-cache-miss branch: BEFORE calling MusicBrainz, consult
  the persistent cache. If a previous SoulSync run already resolved
  this album's release MBID, reuse it. After a successful MB lookup,
  store in BOTH caches. Both calls wrapped in defensive try/except
  so any failure falls through to existing logic.

Tests:
- `tests/metadata/test_album_mbid_cache.py` — 16 cache tests:
  round-trip, idempotent re-record, overwrite semantics, clear_all,
  album+artist independence (no Greatest Hits collisions),
  defensive None-on-empty-input, graceful degradation when the DB
  is unavailable / connection raises / commit fails, schema sanity
  (table + index exist after init).
- `tests/test_album_mbid_consistency.py` — 13 detector tests:
  tag read/write round-trip on real FLAC files, Picard-standard tag
  descriptors, defensive paths (unreadable file, empty input),
  detector behavior (agreement → no flags, lone dissenter → flag,
  ties → no flag, single-track albums → skipped, no-MBID tracks →
  skipped, unresolvable file paths → skipped).
- `tests/metadata/test_metadata_enrichment.py` — added autouse
  fixture monkeypatching the persistent cache to no-op for tests in
  this file. The existing tests pin per-call MB counts and
  in-memory cache state; without the fixture, persistent rows from
  earlier tests would bypass the MB call. Persistent layer has its
  own dedicated tests.

Verified: 1782 tests pass (29 new), ruff clean, smoke test confirms
end-to-end cache round-trip works.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 17:16:39 -07:00

725 lines
25 KiB
Python

from collections import OrderedDict
import types
import sqlite3
import pytest
import requests
from core.metadata import enrichment as me
from core.metadata import artwork as ma
from core.metadata import source as ms
from core.metadata import album_mbid_cache as _album_mbid_cache
@pytest.fixture(autouse=True)
def _isolate_persistent_album_mbid_cache(monkeypatch):
"""The MB release lookup in `core/metadata/source.py` consults the
persistent album-MBID cache (`core/metadata/album_mbid_cache.py`)
before calling MusicBrainz. Tests in this file pin per-test MB
call counts and in-memory cache state — they shouldn't get
bypassed by leftover persistent rows from other tests sharing the
same SQLite database.
Easiest fix: monkeypatch the persistent cache to be a no-op for
these tests. They focus on the in-memory layer + MB call shape;
the persistent layer has its own dedicated tests at
`tests/metadata/test_album_mbid_cache.py`.
"""
monkeypatch.setattr(_album_mbid_cache, 'lookup', lambda *a, **kw: None)
monkeypatch.setattr(_album_mbid_cache, 'record', lambda *a, **kw: False)
class _Config:
def __init__(self, values=None):
self.values = values or {}
def get(self, key, default=None):
return self.values.get(key, default)
class _FakeTag:
def __init__(self, kind, **kwargs):
self.kind = kind
self.kwargs = kwargs
class _FakeID3Tags:
def __init__(self):
self.added = []
def add(self, frame):
self.added.append(frame)
def clear(self):
self.added.clear()
def getall(self, _key):
return []
def keys(self):
return [frame.kind for frame in self.added]
def __len__(self):
return len(self.added)
class _FakeAudio:
def __init__(self):
self.tags = _FakeID3Tags()
self.save_calls = []
self.clear_pictures_calls = 0
def clear_pictures(self):
self.clear_pictures_calls += 1
def add_tags(self):
self.tags = _FakeID3Tags()
def save(self, **kwargs):
self.save_calls.append(kwargs)
class _FakeResponse:
def __init__(self, payload, content_type="image/jpeg"):
self._payload = payload
self._content_type = content_type
def read(self):
return self._payload
def info(self):
return types.SimpleNamespace(get_content_type=lambda: self._content_type)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _FileDB:
def __init__(self, db_path):
self.db_path = db_path
def _get_connection(self):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _fake_symbols(audio):
def _tag_factory(kind):
return lambda **kwargs: _FakeTag(kind, **kwargs)
return types.SimpleNamespace(
File=lambda _path: audio,
APEv2=type("FakeAPEv2", (), {}),
APENoHeaderError=Exception,
FLAC=type("FakeFLAC", (), {}),
Picture=type("FakePicture", (), {"__init__": lambda self: None}),
ID3=_FakeID3Tags,
APIC=_tag_factory("APIC"),
TBPM=_tag_factory("TBPM"),
TCOP=_tag_factory("TCOP"),
TDOR=_tag_factory("TDOR"),
TDRC=_tag_factory("TDRC"),
TCON=_tag_factory("TCON"),
TIT2=_tag_factory("TIT2"),
TALB=_tag_factory("TALB"),
TPE1=_tag_factory("TPE1"),
TPE2=_tag_factory("TPE2"),
TPOS=_tag_factory("TPOS"),
TPUB=_tag_factory("TPUB"),
TRCK=_tag_factory("TRCK"),
TSRC=_tag_factory("TSRC"),
TXXX=_tag_factory("TXXX"),
UFID=_tag_factory("UFID"),
TMED=_tag_factory("TMED"),
MP4=type("FakeMP4", (), {}),
MP4Cover=types.SimpleNamespace(FORMAT_JPEG=1, FORMAT_PNG=2, __call__=lambda *args, **kwargs: ("cover", args, kwargs)),
MP4FreeForm=lambda data: ("freeform", data),
OggVorbis=type("FakeOggVorbis", (), {}),
OggOpus=None,
)
def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_for_non_itunes_sources(monkeypatch):
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"file_organization.collab_artist_mode": "first"}))
monkeypatch.setattr(ms, "get_itunes_client", lambda: (_ for _ in ()).throw(AssertionError("itunes fallback should not run for non-itunes sources")))
context = {
"source": "spotify",
"artist": {"name": "Artist One & Artist Two", "id": "123", "genres": ["rock", "indie"]},
"album": {
"name": "Album One",
"total_tracks": 12,
"release_date": "2024-01-02",
"images": [{"url": "https://img.example/album.jpg"}],
},
"track_info": {
"artists": [{"name": "Artist One"}],
"_source": "spotify",
"track_number": 3,
"disc_number": 2,
"total_tracks": 12,
},
"original_search_result": {
"title": "Song One",
"artists": [{"name": "Artist One"}],
"clean_title": "Song One",
"clean_album": "Album One",
"clean_artist": "Artist One",
"disc_number": 2,
"duration_ms": 180000,
},
}
metadata = me.extract_source_metadata(
context,
context["artist"],
{"is_album": True, "album_name": "Album One", "track_number": 3, "disc_number": 2, "album_image_url": "https://img.example/album.jpg"},
)
assert metadata["source"] == "spotify"
assert metadata["title"] == "Song One"
assert metadata["artist"] == "Artist One"
assert metadata["album_artist"] == "Artist One & Artist Two"
assert metadata["album"] == "Album One"
assert metadata["track_number"] == 3
assert metadata["total_tracks"] == 12
assert metadata["disc_number"] == 2
assert metadata["album_art_url"] == "https://img.example/album.jpg"
def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatch):
audio = _FakeAudio()
symbols = _fake_symbols(audio)
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config())
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(ms, "get_database", lambda: None)
current_metadata = {
"source": "deezer",
"source_track_id": "dz-track",
"source_artist_id": "dz-artist",
"source_album_id": "dz-album",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
}
me.embed_source_ids(audio, current_metadata, context={"track_info": {}, "original_search_result": {}})
current_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
assert "DEEZER_TRACK_ID" in current_descs
assert "DEEZER_ARTIST_ID" in current_descs
audio = _FakeAudio()
symbols = _fake_symbols(audio)
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
legacy_metadata = {
"source": "",
"spotify_track_id": "sp-track",
"spotify_artist_id": "sp-artist",
"spotify_album_id": "sp-album",
"itunes_track_id": "it-track",
"itunes_artist_id": "it-artist",
"itunes_album_id": "it-album",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
}
me.embed_source_ids(audio, legacy_metadata, context={"track_info": {}, "original_search_result": {}})
legacy_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
assert "SPOTIFY_TRACK_ID" in legacy_descs
assert "SPOTIFY_ARTIST_ID" in legacy_descs
assert "SPOTIFY_ALBUM_ID" in legacy_descs
assert "ITUNES_TRACK_ID" in legacy_descs
assert "ITUNES_ARTIST_ID" in legacy_descs
assert "ITUNES_ALBUM_ID" in legacy_descs
def test_embed_source_ids_skips_disabled_source_specific_tags(monkeypatch):
audio = _FakeAudio()
symbols = _fake_symbols(audio)
monkeypatch.setattr(
ms,
"get_config_manager",
lambda: _Config({"deezer.embed_tags": False}),
)
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(ms, "get_database", lambda: None)
metadata = {
"source": "deezer",
"source_track_id": "dz-track",
"source_artist_id": "dz-artist",
"source_album_id": "dz-album",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
}
me.embed_source_ids(audio, metadata, context={"track_info": {}, "original_search_result": {}})
assert audio.tags.added == []
def test_embed_source_ids_writes_musicbrainz_release_year_and_updates_album_year(tmp_path, monkeypatch):
db_path = tmp_path / "music.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)")
conn.execute("CREATE TABLE albums (id TEXT PRIMARY KEY, artist_id TEXT, title TEXT, year INTEGER)")
conn.execute("INSERT INTO artists (id, name) VALUES (?, ?)", ("artist-1", "Artist One"))
conn.execute(
"INSERT INTO albums (id, artist_id, title, year) VALUES (?, ?, ?, ?)",
("album-1", "artist-1", "Album One", None),
)
conn.commit()
conn.close()
class _FakeMBClient:
def get_recording(self, mbid, includes=None):
return {
"isrcs": ["ISRC-123"],
"genres": [{"name": "Post Rock", "count": 10}],
}
def get_release(self, mbid, includes=None):
return {
"release-group": {
"id": "rg-1",
"primary-type": "album",
"first-release-date": "2021-09-17",
},
"artist-credit": [{"artist": {"id": "artist-mb-1"}}],
"status": "Official",
"country": "US",
"barcode": "1234567890",
"media": [{"format": "CD", "tracks": [{"position": 1, "id": "reltrack-1", "recording": {"id": "rec-1"}}]}],
"label-info": [{"catalog-number": "CAT-1"}],
"text-representation": {"script": "Latn"},
"asin": "ASIN1",
}
class _FakeMBService:
def __init__(self):
self.mb_client = _FakeMBClient()
def match_recording(self, title, artist):
return {"mbid": "rec-mbid"}
def match_artist(self, artist):
return {"mbid": "artist-mbid"}
def match_release(self, album, artist):
return {"mbid": "release-mbid"}
audio = _FakeAudio()
symbols = _fake_symbols(audio)
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=_FakeMBService()))
monkeypatch.setattr(
ms,
"get_config_manager",
lambda: _Config(
{
"metadata_enhancement.enabled": True,
"metadata_enhancement.embed_album_art": False,
"metadata_enhancement.tags.write_multi_artist": False,
"musicbrainz.embed_tags": True,
}
),
)
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(ms, "get_database", lambda: _FileDB(str(db_path)))
metadata = {
"source": "musicbrainz",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
"track_number": 1,
"total_tracks": 12,
"disc_number": 1,
}
me.embed_source_ids(audio, metadata, context={"track_info": {}, "original_search_result": {}}, runtime=runtime)
assert metadata["musicbrainz_release_id"] == "release-mbid"
assert metadata["date"] == "2021"
assert any(frame.kind == "TDRC" for frame in audio.tags.added)
assert any(frame.kind == "TSRC" for frame in audio.tags.added)
check = sqlite3.connect(db_path)
check.row_factory = sqlite3.Row
row = check.execute(
"""
SELECT albums.year
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.title = ? AND artists.name = ?
""",
("Album One", "Artist One"),
).fetchone()
check.close()
assert row["year"] == 2021
def test_musicbrainz_release_lookup_failure_does_not_poison_cache(monkeypatch):
class _FakeMBClient:
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
self.mb_client = _FakeMBClient()
def match_recording(self, title, artist):
return None
def match_artist(self, artist):
return {"mbid": "artist-mbid"}
def match_release(self, album, artist):
self.release_calls += 1
if self.release_calls == 1:
raise requests.RequestException("temporary MusicBrainz outage")
return {"mbid": "release-mbid"}
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", {})
service = _FakeMBService()
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=service))
pp = {
"id_tags": {},
"track_title": "Song One",
"artist_name": "Artist One",
"batch_artist_name": "Artist One",
"metadata": {"album": "Album One"},
"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,
}
metadata = {"album": "Album One", "artist": "Artist One"}
ms._process_musicbrainz_source(pp, metadata, _Config({"musicbrainz.embed_tags": True}), runtime, "Song One", "Artist One")
assert service.release_calls == 1
assert ms.mb_release_cache == {}
poisoned_norm_key = (ms.normalize_album_cache_key("Album One"), "artist one")
poisoned_exact_key = ("album one", "artist one")
ms.mb_release_cache[poisoned_norm_key] = ""
ms.mb_release_cache[poisoned_exact_key] = ""
ms._process_musicbrainz_source(pp, metadata, _Config({"musicbrainz.embed_tags": True}), runtime, "Song One", "Artist One")
assert service.release_calls == 2
def test_source_processors_do_not_swallow_programmer_errors():
class _BoomClient:
def search_track(self, artist_name, track_title):
raise ValueError("boom")
runtime = types.SimpleNamespace(deezer_worker=types.SimpleNamespace(client=_BoomClient()))
pp = {
"id_tags": {},
"batch_artist_name": "Artist One",
"release_year": None,
}
metadata = {"album": "Album One"}
with pytest.raises(ValueError):
ms._process_deezer_source(pp, metadata, _Config({"deezer.embed_tags": True}), runtime, "Song One", "Artist One")
def test_musicbrainz_caches_evict_oldest_entries():
release_cache = OrderedDict()
detail_cache = OrderedDict()
ms._bounded_cache_set(release_cache, ("album-1", "artist"), "release-1", 2)
ms._bounded_cache_set(release_cache, ("album-2", "artist"), "release-2", 2)
assert list(release_cache.keys()) == [("album-1", "artist"), ("album-2", "artist")]
assert ms._bounded_cache_get(release_cache, ("album-1", "artist")) == "release-1"
ms._bounded_cache_set(release_cache, ("album-3", "artist"), "release-3", 2)
assert list(release_cache.keys()) == [("album-1", "artist"), ("album-3", "artist")]
ms._bounded_cache_set(detail_cache, "release-1", {"title": "One"}, 1)
ms._bounded_cache_set(detail_cache, "release-2", {"title": "Two"}, 1)
assert list(detail_cache.keys()) == ["release-2"]
def test_enhance_file_metadata_forwards_runtime_to_source_embedding(monkeypatch):
audio = _FakeAudio()
symbols = _fake_symbols(audio)
seen = {}
runtime = types.SimpleNamespace(marker="runtime")
monkeypatch.setattr(me, "get_config_manager", lambda: _Config(
{
"metadata_enhancement.enabled": True,
"metadata_enhancement.embed_album_art": False,
"metadata_enhancement.tags.write_multi_artist": False,
}
))
monkeypatch.setattr(me, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(me, "strip_all_non_audio_tags", lambda file_path: {"apev2_stripped": False, "apev2_tag_count": 0})
monkeypatch.setattr(
me,
"extract_source_metadata",
lambda context, artist, album_info: {
"source": "deezer",
"source_track_id": "dz-track",
"source_artist_id": "dz-artist",
"source_album_id": "dz-album",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
"track_number": 3,
"total_tracks": 12,
"disc_number": 2,
"date": "2024",
"genre": "Rock",
},
)
monkeypatch.setattr(
me,
"embed_source_ids",
lambda audio_file, metadata, context, runtime=None: seen.setdefault("runtime", runtime),
)
monkeypatch.setattr(me, "embed_album_art_metadata", lambda *args, **kwargs: None)
monkeypatch.setattr(me, "verify_metadata_written", lambda file_path: True)
result = me.enhance_file_metadata(
"song.flac",
{"_audio_quality": ""},
{"name": "Artist One"},
{},
runtime=runtime,
)
assert result is True
assert seen["runtime"] is runtime
def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch):
audio = _FakeAudio()
symbols = _fake_symbols(audio)
strip_calls = []
verify_calls = []
monkeypatch.setattr(me, "get_config_manager", lambda: _Config(
{
"metadata_enhancement.enabled": True,
"metadata_enhancement.embed_album_art": False,
"metadata_enhancement.tags.write_multi_artist": False,
}
))
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config())
monkeypatch.setattr(me, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
monkeypatch.setattr(ms, "get_database", lambda: None)
monkeypatch.setattr(me, "strip_all_non_audio_tags", lambda file_path: strip_calls.append(file_path) or {"apev2_stripped": False, "apev2_tag_count": 0})
monkeypatch.setattr(
me,
"extract_source_metadata",
lambda context, artist, album_info: {
"source": "deezer",
"source_track_id": "dz-track",
"source_artist_id": "dz-artist",
"source_album_id": "dz-album",
"title": "Song One",
"artist": "Artist One",
"album_artist": "Artist One",
"album": "Album One",
"track_number": 3,
"total_tracks": 12,
"disc_number": 2,
"date": "2024",
"genre": "Rock",
"musicbrainz_release_id": "mb-release-1",
},
)
monkeypatch.setattr(me, "embed_album_art_metadata", lambda *args, **kwargs: None)
monkeypatch.setattr(me, "verify_metadata_written", lambda file_path: verify_calls.append(file_path) or True)
album_info = {}
result = me.enhance_file_metadata(
"song.flac",
{"_audio_quality": ""},
{"name": "Artist One"},
album_info,
)
assert result is True
assert strip_calls == ["song.flac"]
assert verify_calls == ["song.flac"]
assert audio.clear_pictures_calls == 1
assert len(audio.save_calls) == 2
assert album_info["musicbrainz_release_id"] == "mb-release-1"
assert any(frame.kind == "TIT2" for frame in audio.tags.added)
assert any(frame.kind == "TPE1" for frame in audio.tags.added)
assert any(frame.kind == "TXXX" and frame.kwargs.get("desc") == "DEEZER_TRACK_ID" for frame in audio.tags.added)
def test_download_cover_art_uses_album_context_image_url(tmp_path, monkeypatch):
monkeypatch.setattr(ma, "get_config_manager", lambda: _Config(
{
"metadata_enhancement.cover_art_download": True,
"metadata_enhancement.prefer_caa_art": False,
}
))
monkeypatch.setattr(ma.urllib.request, "urlopen", lambda *args, **kwargs: _FakeResponse(b"cover-bytes"))
target_dir = tmp_path / "Album One"
target_dir.mkdir()
ma.download_cover_art(
{},
str(target_dir),
{"album": {"image_url": "https://img.example/album.jpg"}},
)
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"] == []