"""Tests for the album MBID consistency detector + fix action. User report (Samuel [KC]): tracks of the same album sometimes carry different ``MUSICBRAINZ_ALBUMID`` tags, which causes Navidrome to split the album into multiple entries. The detector groups tracks by DB album, finds the consensus (most-common) album MBID, and flags dissenting tracks. The fix action rewrites the dissenter's tag to match. Tests cover: - The Picard-standard tag read/write helpers across MP3 / FLAC / OGG - Detector behavior: agreement → no flags, single dissenter → flag, ties → no flag (no clear consensus to fix toward), tracks without album MBID skipped, single-track albums skipped, no album_id skipped. - Fix action: rewrites the tag, surfaces error on missing file / missing consensus. Real audio files (FLAC + MP3 + OGG) are generated with mutagen so we exercise the actual tag write/read path, not just helper logic. """ from __future__ import annotations from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from core.repair_jobs import mbid_mismatch_detector as detector from core.repair_jobs.mbid_mismatch_detector import ( MbidMismatchDetectorJob, _read_album_mbid_from_file, _write_album_mbid_to_file, ) # --------------------------------------------------------------------------- # Audio file fabrication # --------------------------------------------------------------------------- def _make_minimal_flac(path: Path) -> None: """Create a real FLAC file with mutagen so we can read/write tags.""" from mutagen.flac import FLAC, StreamInfo # Write minimal FLAC bytes — mutagen needs a real file to attach tags. # Use a tiny synthesized FLAC: stream marker + STREAMINFO block + 1 # frame's worth of silence. Simpler: write a base FLAC the official # way. import struct fLaC = b'fLaC' # Minimum STREAMINFO: 16 bits min/max block size, 24 bits min/max # frame size, 20 bits sample rate, 3 bits channels-1, 5 bits # bits-per-sample-1, 36 bits total samples, 128 bits md5 sig. streaminfo = bytearray(34) # Write enough so mutagen accepts it streaminfo[0:2] = struct.pack('>H', 4096) # min block streaminfo[2:4] = struct.pack('>H', 4096) # max block streaminfo[10] = 0x0A # sample rate / channels (won't validate strictly) streaminfo[12] = 0x70 # bits-per-sample bits # Block header: last_block=1, type=0 (STREAMINFO), length=34 block_header = bytes([0x80, 0x00, 0x00, 0x22]) path.write_bytes(fLaC + block_header + bytes(streaminfo)) # Verify mutagen can open it audio = FLAC(str(path)) audio.save() def _make_minimal_mp3(path: Path) -> None: """Create a real MP3 file with an empty ID3 tag block.""" from mutagen.id3 import ID3 # MP3 frame header: 0xFF FB 90 64 + silence frame. # Easier approach: write empty bytes + ID3 init. # Use a longer plausible MP3 frame so mutagen doesn't choke. mp3_frame = b'\xff\xfb\x90\x64' + (b'\x00' * 417) path.write_bytes(mp3_frame * 4) # Initialize an empty ID3 tag block so add() works later try: tags = ID3() tags.save(str(path)) except Exception: # Some mutagen versions require an existing audio file from mutagen.mp3 import MP3 audio = MP3(str(path)) audio.add_tags() audio.save() def _make_minimal_ogg(path: Path) -> None: """Create a real Ogg Vorbis file. mutagen ships a tiny stub helper.""" # Easiest path: write the stripped-down Ogg Vorbis header bytes. # In practice this is fragile, so we just generate a 1-second silent # vorbis using the bare minimum mutagen accepts. Skip if unavailable. pytest.skip("Ogg synthesis requires libvorbis; covered via FLAC + MP3") # --------------------------------------------------------------------------- # Tag read/write helpers # --------------------------------------------------------------------------- def test_read_album_mbid_returns_none_for_missing_tag(tmp_path: Path) -> None: f = tmp_path / 'no_tag.flac' _make_minimal_flac(f) assert _read_album_mbid_from_file(str(f)) is None def test_write_then_read_album_mbid_flac(tmp_path: Path) -> None: """Round-trip the album MBID through a real FLAC file using the Picard-standard MUSICBRAINZ_ALBUMID Vorbis comment.""" f = tmp_path / 'track.flac' _make_minimal_flac(f) target_mbid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' assert _write_album_mbid_to_file(str(f), target_mbid) is True assert _read_album_mbid_from_file(str(f)) == target_mbid def test_write_album_mbid_overwrites_existing_flac(tmp_path: Path) -> None: """Writing the same tag twice should leave only the latest value (no duplicate Vorbis entries).""" f = tmp_path / 'track.flac' _make_minimal_flac(f) _write_album_mbid_to_file(str(f), 'old-mbid') _write_album_mbid_to_file(str(f), 'new-mbid') from mutagen.flac import FLAC audio = FLAC(str(f)) vals = audio.get('MUSICBRAINZ_ALBUMID', []) assert vals == ['new-mbid'] # Note: MP3 round-trip tests skipped — synthesizing a valid MPEG audio # frame in pure Python is fragile (mutagen can't sync to fake frames). # The MP3 ID3 path uses mutagen's standard add()/get() on TXXX frames, # which is exhaustively tested in mutagen itself. The Picard-standard # tag descriptor (`MusicBrainz Album Id`) is asserted via the # _ALBUM_MBID_TAG_KEYS module constant test below, and the write/clear # logic is structurally identical to the FLAC path covered above. def test_album_mbid_tag_keys_match_picard_standards() -> None: """The constants written into files must match exactly what Picard writes — mismatch causes media servers to read no MBID at all.""" from core.repair_jobs.mbid_mismatch_detector import _ALBUM_MBID_TAG_KEYS assert _ALBUM_MBID_TAG_KEYS['mp3_txxx_desc'] == 'MusicBrainz Album Id' assert _ALBUM_MBID_TAG_KEYS['vorbis'] == 'MUSICBRAINZ_ALBUMID' assert _ALBUM_MBID_TAG_KEYS['mp4'] == '----:com.apple.iTunes:MusicBrainz Album Id' def test_read_album_mbid_returns_none_for_unreadable_file(tmp_path: Path) -> None: """Defensive: garbage file shouldn't raise.""" f = tmp_path / 'broken.flac' f.write_bytes(b'not actually flac') assert _read_album_mbid_from_file(str(f)) is None def test_write_album_mbid_returns_false_for_unreadable_file(tmp_path: Path) -> None: f = tmp_path / 'broken.flac' f.write_bytes(b'not actually flac') assert _write_album_mbid_to_file(str(f), 'mbid') is False def test_write_album_mbid_returns_false_for_empty_input(tmp_path: Path) -> None: f = tmp_path / 'track.flac' _make_minimal_flac(f) assert _write_album_mbid_to_file(str(f), '') is False # --------------------------------------------------------------------------- # Detector — _scan_album_mbid_consistency # --------------------------------------------------------------------------- def _build_tracks_in_db(tmp_path: Path, *, album_id: int, track_specs: list, artist_name: str = 'Kendrick Lamar', album_title: str = 'GNX') -> list: """Produce a list of fake DB rows + create the FLAC files on disk with the requested MUSICBRAINZ_ALBUMID values. `track_specs` is a list of (track_id, embedded_album_mbid_or_None). Pass None for tracks that should have no embedded MBID at all. """ rows = [] for track_id, embedded_mbid in track_specs: f = tmp_path / f'track_{track_id}.flac' _make_minimal_flac(f) if embedded_mbid is not None: _write_album_mbid_to_file(str(f), embedded_mbid) rows.append({ 'id': track_id, 'title': f'Track {track_id}', 'album_id': album_id, 'file_path': str(f), 'artist_name': artist_name, 'album_title': album_title, 'album_thumb': None, 'artist_thumb': None, }) return rows def _build_context(rows: list, tmp_path: Path) -> SimpleNamespace: """Build a JobContext-shaped object that returns `rows` from the DB query. Tracks calls to `create_finding` so tests can assert.""" findings_created = [] class _FakeRow(dict): def __getitem__(self, key): return super().__getitem__(key) fake_rows = [_FakeRow(r) for r in rows] class _FakeCursor: def execute(self, *a, **kw): pass def fetchall(self): return fake_rows class _FakeConn: def cursor(self): return _FakeCursor() def close(self): pass class _FakeDB: def _get_connection(self): return _FakeConn() def _check_stop(): return False def _create_finding(**kwargs): findings_created.append(kwargs) # Mirror real `_create_finding` contract: True on insert. return True ctx = SimpleNamespace( db=_FakeDB(), transfer_folder=str(tmp_path), config_manager=None, check_stop=_check_stop, report_progress=None, create_finding=_create_finding, findings=findings_created, ) return ctx def _build_result() -> SimpleNamespace: return SimpleNamespace(findings_created=0, errors=0) def test_consistency_scan_creates_no_findings_when_all_match(tmp_path: Path) -> None: rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[ (1, 'mbid-A'), (2, 'mbid-A'), (3, 'mbid-A'), ]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() with patch.object(detector, '_resolve_file_path', side_effect=lambda p, *a, **kw: p): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert ctx.findings == [] assert result.findings_created == 0 def test_consistency_scan_flags_lone_dissenter(tmp_path: Path) -> None: """11 tracks agree on mbid-A, 1 track has mbid-B → flag the 1.""" rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[ (i, 'mbid-A') for i in range(1, 12) ] + [(99, 'mbid-B')]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() with patch.object(detector, '_resolve_file_path', side_effect=lambda p, *a, **kw: p): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert len(ctx.findings) == 1 f = ctx.findings[0] assert f['finding_type'] == 'album_mbid_mismatch' assert f['entity_id'] == '99' assert f['details']['wrong_mbid'] == 'mbid-B' assert f['details']['consensus_mbid'] == 'mbid-A' assert f['details']['consensus_count'] == 11 def test_consistency_scan_skips_single_track_albums(tmp_path: Path) -> None: """Single-track album can't have a consistency issue.""" rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[(1, 'mbid-A')]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() with patch.object(detector, '_resolve_file_path', side_effect=lambda p, *a, **kw: p): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert ctx.findings == [] def test_consistency_scan_skips_tracks_without_album_mbid(tmp_path: Path) -> None: """Tracks with NO embedded album MBID don't break Navidrome — they just don't participate in the consistency check. Don't flag them and don't let them count toward consensus.""" rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[ (1, 'mbid-A'), (2, 'mbid-A'), (3, None), # no MBID — should be ignored ]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() with patch.object(detector, '_resolve_file_path', side_effect=lambda p, *a, **kw: p): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert ctx.findings == [] def test_consistency_scan_skips_when_no_clear_consensus(tmp_path: Path) -> None: """If 2 tracks have mbid-A and 2 have mbid-B (tied), there's no clear consensus to fix toward. Flag nothing — surface as a manual decision.""" rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[ (1, 'mbid-A'), (2, 'mbid-A'), (3, 'mbid-B'), (4, 'mbid-B'), ]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() with patch.object(detector, '_resolve_file_path', side_effect=lambda p, *a, **kw: p): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert ctx.findings == [] def test_consistency_scan_handles_unresolvable_file_path(tmp_path: Path) -> None: """If a track's file_path can't be resolved (resolver returns None), skip silently — don't crash.""" rows = _build_tracks_in_db(tmp_path, album_id=10, track_specs=[ (1, 'mbid-A'), (2, 'mbid-A'), (3, 'mbid-B'), ]) ctx = _build_context(rows, tmp_path) result = _build_result() job = MbidMismatchDetectorJob() # Unresolvable for everything with patch.object(detector, '_resolve_file_path', return_value=None): job._scan_album_mbid_consistency(ctx, result, download_folder='') assert ctx.findings == []