soulsync/tests/test_album_mbid_consistency.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

353 lines
13 KiB
Python

"""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)
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 == []