soulsync/tests/test_album_mbid_consistency.py

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