355 lines
13 KiB
Python
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 == []
|