From 4b15fe0b75c7029951fea9b3831d1bf4b470e580 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 3 May 2026 17:16:39 -0700 Subject: [PATCH] Fix album MBID inconsistency: detector + persistent release-MBID cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/metadata/album_mbid_cache.py | 175 ++++++++++ core/metadata/source.py | 35 +- core/repair_jobs/mbid_mismatch_detector.py | 304 +++++++++++++++++- core/repair_worker.py | 43 +++ database/music_database.py | 22 ++ tests/metadata/test_album_mbid_cache.py | 207 ++++++++++++ tests/metadata/test_metadata_enrichment.py | 19 ++ tests/test_album_mbid_consistency.py | 353 +++++++++++++++++++++ webui/static/helper.js | 1 + 9 files changed, 1152 insertions(+), 7 deletions(-) create mode 100644 core/metadata/album_mbid_cache.py create mode 100644 tests/metadata/test_album_mbid_cache.py create mode 100644 tests/test_album_mbid_consistency.py diff --git a/core/metadata/album_mbid_cache.py b/core/metadata/album_mbid_cache.py new file mode 100644 index 00000000..2d92247e --- /dev/null +++ b/core/metadata/album_mbid_cache.py @@ -0,0 +1,175 @@ +"""Persistent MusicBrainz release-MBID cache for albums. + +The original in-memory `mb_release_cache` in `core/metadata/source.py` +maps `(normalized_album_name, artist_name) -> release_mbid` so per-track +enrichment of the same album hits the cache and writes the same +``MUSICBRAINZ_ALBUMID`` to every track's tags. That cache is a bounded +``OrderedDict`` (4096 entries) — bounded means it can evict entries +between tracks of the same album when other albums are processed in +between. Server restart drops it entirely. Either case can produce +inconsistent album MBIDs across tracks of the same album, which causes +Navidrome (and other media servers that group by album MBID) to split +the album into multiple entries. + +This module is the persistent layer behind that cache. Same key shape, +backed by a tiny SQLite table so a successful lookup remembered ONCE +applies to every future track of the same album for the lifetime of +the install — not just the bounded in-memory window. + +Strict additive design: every public function is wrapped in try/except +and degrades to a None / no-op return on any database error. The +existing in-memory cache + MusicBrainz lookup stays behind it as the +authoritative fallback. If this module breaks, downloads continue +exactly as they would today — just without the persistent benefit. +""" + +from __future__ import annotations + +import threading +from typing import Optional + +from utils.logging_config import get_logger + + +logger = get_logger("metadata.album_mbid_cache") + + +# Lazy DB accessor — the cache module shouldn't trigger MusicDatabase +# import at module-load time (circular-import risk when source.py is +# imported during database initialization). +_db_factory_lock = threading.Lock() +_db_factory = None + + +def _get_database(): + """Resolve the MusicDatabase singleton lazily. + + Returns None if anything goes wrong — callers MUST handle a None + return as "cache unavailable, fall through to MB lookup." + """ + global _db_factory + with _db_factory_lock: + if _db_factory is None: + try: + from database.music_database import get_database + _db_factory = get_database + except Exception as exc: + logger.warning(f"Persistent MBID cache: could not load database module: {exc}") + return None + try: + return _db_factory() + except Exception as exc: + logger.warning(f"Persistent MBID cache: database accessor failed: {exc}") + return None + + +def lookup(normalized_album_key: str, artist_key: str) -> Optional[str]: + """Read a cached release MBID for the given (album, artist) pair. + + Returns the stored MBID string if found, otherwise None. Never + raises — DB errors degrade silently to "cache miss" so the caller + falls through to MusicBrainz like it does today. + + Args: + normalized_album_key: Output of ``normalize_album_cache_key`` — + already lowercased and stripped of edition parentheticals. + artist_key: Lowercased artist name (caller's responsibility to + pass a normalized key — keeps the schema uniform). + """ + if not normalized_album_key or not artist_key: + return None + + db = _get_database() + if db is None: + return None + + conn = None + try: + conn = db._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT release_mbid FROM mb_album_release_cache " + "WHERE normalized_album_key = ? AND artist_key = ? LIMIT 1", + (normalized_album_key, artist_key), + ) + row = cursor.fetchone() + if row: + mbid = row[0] if not hasattr(row, 'keys') else row['release_mbid'] + return mbid or None + except Exception as exc: + logger.debug(f"Persistent MBID cache lookup failed: {exc}") + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + return None + + +def record(normalized_album_key: str, artist_key: str, release_mbid: str) -> bool: + """Persist a (album, artist) -> release_mbid mapping. + + Idempotent — uses INSERT OR REPLACE so re-recording the same key + just refreshes the timestamp. Returns True on success, False on + any failure. Failure is logged at debug level and never propagated + so a flaky DB write can't break the enrichment path. + """ + if not normalized_album_key or not artist_key or not release_mbid: + return False + + db = _get_database() + if db is None: + return False + + conn = None + try: + conn = db._get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO mb_album_release_cache " + "(normalized_album_key, artist_key, release_mbid, updated_at) " + "VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + (normalized_album_key, artist_key, release_mbid), + ) + conn.commit() + return True + except Exception as exc: + logger.debug(f"Persistent MBID cache record failed: {exc}") + return False + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +def clear_all() -> bool: + """Wipe the persistent cache. Used by tests and by the maintenance + endpoint when a user wants to force a fresh MusicBrainz re-lookup + (e.g. after fixing widespread MBID inconsistencies).""" + db = _get_database() + if db is None: + return False + + conn = None + try: + conn = db._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM mb_album_release_cache") + conn.commit() + return True + except Exception as exc: + logger.warning(f"Persistent MBID cache clear failed: {exc}") + return False + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +__all__ = ["lookup", "record", "clear_all"] diff --git a/core/metadata/source.py b/core/metadata/source.py index fc5f2b12..4db86101 100644 --- a/core/metadata/source.py +++ b/core/metadata/source.py @@ -255,7 +255,8 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti album_name_for_mb = metadata.get("album", "") if album_name_for_mb: artist_key = (pp.get("batch_artist_name") or artist_name).lower().strip() - rc_key_norm = (normalize_album_cache_key(album_name_for_mb), artist_key) + normalized_album_key = normalize_album_cache_key(album_name_for_mb) + rc_key_norm = (normalized_album_key, artist_key) rc_key_exact = (album_name_for_mb.lower().strip(), artist_key) release_mbid = None with mb_release_cache_lock: @@ -265,12 +266,38 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti if cached: release_mbid = cached else: - rc_result = _call_source_lookup("MusicBrainz release", mb_service.match_release, album_name_for_mb, artist_name) - if rc_result and rc_result.get("mbid"): - release_mbid = rc_result["mbid"] + # Persistent cache check BEFORE the live MB lookup. If a + # previous SoulSync run already resolved this album's + # release MBID, reuse it — guarantees every track of the + # same album gets the SAME MUSICBRAINZ_ALBUMID tag, even + # across server restarts and after the in-memory bounded + # cache evicts the entry. Strictly additive: any failure + # in the persistent lookup falls through to the live MB + # query exactly as today. + try: + from core.metadata import album_mbid_cache as _persisted_cache + persisted = _persisted_cache.lookup(normalized_album_key, artist_key) + except Exception: + persisted = None + + if persisted: + release_mbid = persisted + else: + rc_result = _call_source_lookup("MusicBrainz release", mb_service.match_release, album_name_for_mb, artist_name) + if rc_result and rc_result.get("mbid"): + release_mbid = rc_result["mbid"] + if release_mbid: _bounded_cache_set(mb_release_cache, rc_key_norm, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES) _bounded_cache_set(mb_release_cache, rc_key_exact, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES) + # Also persist for future SoulSync runs. Defensive + # try/except so a DB write failure can't block the + # in-memory store + tag write that follow. + try: + from core.metadata import album_mbid_cache as _persisted_cache + _persisted_cache.record(normalized_album_key, artist_key, release_mbid) + except Exception: + pass pp["release_mbid"] = release_mbid or "" if pp["release_mbid"]: pp["id_tags"]["MUSICBRAINZ_RELEASE_ID"] = pp["release_mbid"] diff --git a/core/repair_jobs/mbid_mismatch_detector.py b/core/repair_jobs/mbid_mismatch_detector.py index e8aa476e..4edf5c5f 100644 --- a/core/repair_jobs/mbid_mismatch_detector.py +++ b/core/repair_jobs/mbid_mismatch_detector.py @@ -8,7 +8,9 @@ SoulSync shows them correctly. """ import os +from collections import Counter, defaultdict from difflib import SequenceMatcher +from typing import Optional from core.library.path_resolver import resolve_library_file_path from core.repair_jobs import register_job @@ -17,7 +19,8 @@ from utils.logging_config import get_logger logger = get_logger("repair_job.mbid_mismatch") -# Tag name → format mappings (must match web_server.py write logic) +# Tag name → format mappings for the TRACK MBID (existing detection). +# Must match web_server.py write logic. _MBID_TAG_KEYS = { # MP3 (ID3): UFID frame with owner 'http://musicbrainz.org' 'mp3_ufid_owner': 'http://musicbrainz.org', @@ -27,6 +30,14 @@ _MBID_TAG_KEYS = { 'mp4': '----:com.apple.iTunes:MusicBrainz Track Id', } +# Tag name → format mappings for the ALBUM MBID (new in this PR). +# Same Picard standards as `core/metadata/source.py:ID3_TAG_MAP` etc. +_ALBUM_MBID_TAG_KEYS = { + 'mp3_txxx_desc': 'MusicBrainz Album Id', # ID3 TXXX frame description + 'vorbis': 'MUSICBRAINZ_ALBUMID', # FLAC/OGG vorbis comment + 'mp4': '----:com.apple.iTunes:MusicBrainz Album Id', +} + TITLE_SIMILARITY_THRESHOLD = 0.55 @@ -166,6 +177,115 @@ def _remove_mbid_from_file(file_path): return False +def _read_album_mbid_from_file(file_path: str) -> Optional[str]: + """Read the embedded MusicBrainz Album Id from an audio file's tags. + + Mirrors `_read_file_tags` but for the ALBUM MBID. Returns None when + the file has no album MBID tag, when the file is unreadable, or + when the tag format is unsupported. + """ + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3 + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + audio = MutagenFile(file_path) + if audio is None: + return None + + if isinstance(audio.tags, ID3): + txxx_key = f'TXXX:{_ALBUM_MBID_TAG_KEYS["mp3_txxx_desc"]}' + tag = audio.tags.get(txxx_key) + if tag and tag.text: + value = tag.text[0] + return str(value).strip() if value else None + # Some taggers use lowercase variant + txxx_key_lower = 'TXXX:MUSICBRAINZ_ALBUMID' + tag = audio.tags.get(txxx_key_lower) + if tag and tag.text: + value = tag.text[0] + return str(value).strip() if value else None + return None + + if isinstance(audio, (FLAC, OggVorbis)): + for key in (_ALBUM_MBID_TAG_KEYS['vorbis'], 'musicbrainz_albumid'): + vals = audio.get(key, []) + if vals: + value = vals[0] + return str(value).strip() if value else None + return None + + if isinstance(audio, MP4): + mp4_key = _ALBUM_MBID_TAG_KEYS['mp4'] + vals = audio.get(mp4_key, []) + if vals: + raw = vals[0] + value = raw.decode('utf-8', errors='ignore') if isinstance(raw, bytes) else str(raw) + return value.strip() if value else None + return None + + return None + except Exception as e: + logger.debug("Error reading album MBID from %s: %s", file_path, e) + return None + + +def _write_album_mbid_to_file(file_path: str, new_mbid: str) -> bool: + """Rewrite the embedded MusicBrainz Album Id tag. + + Used by the fix action to bring a track's album MBID in line with + the consensus across other tracks of the same album. Returns True + when the tag was written and the file saved. + """ + if not new_mbid: + return False + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TXXX + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + audio = MutagenFile(file_path) + if audio is None: + return False + + if isinstance(audio.tags, ID3): + # Wipe any conflicting variants first so we don't end up with + # two TXXX frames pointing at different MBIDs. + for key in ('TXXX:MUSICBRAINZ_ALBUMID', + f'TXXX:{_ALBUM_MBID_TAG_KEYS["mp3_txxx_desc"]}'): + if key in audio.tags: + del audio.tags[key] + audio.tags.add(TXXX( + encoding=3, + desc=_ALBUM_MBID_TAG_KEYS['mp3_txxx_desc'], + text=[new_mbid], + )) + audio.save() + return True + + if isinstance(audio, (FLAC, OggVorbis)): + for key in ('musicbrainz_albumid',): + if key in audio: + del audio[key] + audio[_ALBUM_MBID_TAG_KEYS['vorbis']] = [new_mbid] + audio.save() + return True + + if isinstance(audio, MP4): + audio[_ALBUM_MBID_TAG_KEYS['mp4']] = [new_mbid.encode('utf-8')] + audio.save() + return True + + return False + except Exception as e: + logger.error("Error writing album MBID to %s: %s", file_path, e) + return False + + def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_manager=None): """Backwards-compat wrapper. Use ``resolve_library_file_path`` directly.""" return resolve_library_file_path( @@ -343,19 +463,197 @@ class MbidMismatchDetectorJob(RepairJob): if context.update_progress: context.update_progress(total, total) - logger.info("MBID mismatch scan: %d files scanned, %d with MBIDs verified, %d mismatches found", + logger.info("MBID mismatch scan (track-level): %d files scanned, %d with MBIDs verified, %d mismatches found", total, checked, result.findings_created) + # Phase 2: Album MBID consistency check. + # + # Tracks of the same album that carry different MUSICBRAINZ_ALBUMID + # tags cause Navidrome (and other media servers grouping by album + # MBID) to split the album into multiple entries. Reported by user + # Samuel [KC]. Detection strategy: group tracks by DB album_id, + # find the consensus (most-common) album MBID, flag the dissenters. + # No MusicBrainz API calls — this is a pure consistency check, so + # it doesn't compete with the rate-limited track scan above. + track_findings_so_far = result.findings_created + self._scan_album_mbid_consistency(context, result, download_folder) + album_findings = result.findings_created - track_findings_so_far + if context.report_progress: context.report_progress( scanned=total, total=total, phase='Complete', - log_line=f'Verified {checked} MBIDs — {result.findings_created} mismatches found', + log_line=( + f'Verified {checked} track MBIDs ({track_findings_so_far} mismatches) — ' + f'album consistency check found {album_findings} dissenters' + ), log_type='success' if result.findings_created == 0 else 'warning' ) return result + def _scan_album_mbid_consistency(self, context: JobContext, result: JobResult, + download_folder: str) -> None: + """Group tracks by DB album, flag tracks whose embedded album + MBID differs from the consensus across the album's other tracks.""" + # Pull tracks grouped by album. Singles (album NULL) skipped — they + # can't have a consistency issue. + rows = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, t.album_id, t.file_path, + ar.name AS artist_name, al.title AS album_title, + al.thumb_url AS album_thumb, ar.thumb_url AS artist_thumb + FROM tracks t + LEFT JOIN artists ar ON ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.file_path IS NOT NULL AND t.file_path != '' + AND t.album_id IS NOT NULL + """) + rows = cursor.fetchall() + except Exception as e: + logger.error("Album MBID consistency scan: DB fetch failed: %s", e, exc_info=True) + return + finally: + if conn: + conn.close() + + if not rows: + return + + # Group by album_id. Read each track's embedded album MBID — only + # include rows where the read succeeded (skips files that don't + # have an album MBID at all; those don't break Navidrome since + # there's no MBID for it to disagree on). + by_album: dict = defaultdict(list) + for row in rows: + if context.check_stop(): + return + track_id = row['id'] + album_id = row['album_id'] + file_path = row['file_path'] + + resolved = _resolve_file_path( + file_path, context.transfer_folder, download_folder, + config_manager=context.config_manager, + ) + if not resolved: + continue + album_mbid = _read_album_mbid_from_file(resolved) + if not album_mbid: + continue + by_album[album_id].append({ + 'track_id': track_id, + 'title': row['title'], + 'album_title': row['album_title'], + 'artist_name': row['artist_name'], + 'album_thumb': row['album_thumb'], + 'artist_thumb': row['artist_thumb'], + 'file_path': file_path, + 'resolved': resolved, + 'album_mbid': album_mbid, + }) + + if context.report_progress: + context.report_progress( + phase=f'Checking album MBID consistency across {len(by_album)} albums...', + log_type='info', + ) + + for album_id, tracks_in_album in by_album.items(): + if context.check_stop(): + return + # Need at least 2 tracks to detect a mismatch. + if len(tracks_in_album) < 2: + continue + + mbid_counts = Counter(t['album_mbid'] for t in tracks_in_album) + if len(mbid_counts) == 1: + continue # All tracks agree → nothing to flag + + consensus_mbid, consensus_count = mbid_counts.most_common(1)[0] + + # Defensive: if no MBID has a clear plurality (e.g. 3 tracks, + # 3 different MBIDs), skip rather than picking a random one. + # Counter.most_common returns ties in arbitrary order; we don't + # want to fix a track to a "consensus" that's really a 1/N tie. + second_count = mbid_counts.most_common(2)[1][1] if len(mbid_counts) > 1 else 0 + if consensus_count == second_count: + logger.info( + "Album %s has tied album MBID counts %s — no clear consensus, skipping", + album_id, dict(mbid_counts), + ) + continue + + for track in tracks_in_album: + if track['album_mbid'] == consensus_mbid: + continue + self._create_album_mbid_mismatch_finding( + context, result, track, + consensus_mbid=consensus_mbid, + consensus_count=consensus_count, + total_tracks=len(tracks_in_album), + ) + + def _create_album_mbid_mismatch_finding(self, context: JobContext, result: JobResult, + track: dict, consensus_mbid: str, + consensus_count: int, total_tracks: int) -> None: + """Create a finding for a track whose album MBID disagrees with + the consensus across the album's other tracks.""" + title = track['title'] + artist_name = track['artist_name'] + album_title = track['album_title'] + if context.report_progress: + context.report_progress( + log_line=( + f'Album MBID mismatch: "{title}" — has {track["album_mbid"][:8]}…, ' + f'consensus is {consensus_mbid[:8]}… ({consensus_count}/{total_tracks} tracks)' + ), + log_type='warning' + ) + if context.create_finding: + try: + context.create_finding( + job_id=self.job_id, + finding_type='album_mbid_mismatch', + severity='warning', + entity_type='track', + entity_id=str(track['track_id']), + file_path=track['file_path'], + title=f'Album MBID mismatch: {title or "Unknown"}', + description=( + f'Track "{title}" by {artist_name or "Unknown"} on album ' + f'"{album_title or "Unknown"}" has a different ' + f'MusicBrainz Album Id than the album\'s other tracks. ' + f'This causes media servers like Navidrome to split the album.' + ), + details={ + 'track_id': track['track_id'], + 'title': title, + 'artist': artist_name, + 'album': album_title, + 'file_path': track['file_path'], + 'wrong_mbid': track['album_mbid'], + 'consensus_mbid': consensus_mbid, + 'consensus_count': consensus_count, + 'total_tracks_with_mbid': total_tracks, + 'reason': ( + f'{consensus_count}/{total_tracks} tracks of this album use ' + f'MBID {consensus_mbid}; this track uses {track["album_mbid"]}' + ), + 'album_thumb_url': track['album_thumb'] or None, + 'artist_thumb_url': track['artist_thumb'] or None, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating album MBID mismatch finding for track %s: %s", + track['track_id'], e) + result.errors += 1 + def _create_mismatch_finding(self, context, result, track_id, title, artist_name, album_title, file_path, album_thumb, artist_thumb, mbid, mb_title, mb_artist, reason): diff --git a/core/repair_worker.py b/core/repair_worker.py index 23bdc228..3c42ef1e 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -841,6 +841,7 @@ class RepairWorker: 'duplicate_tracks': self._fix_duplicates, 'single_album_redundant': self._fix_single_album_redundant, 'mbid_mismatch': self._fix_mbid_mismatch, + 'album_mbid_mismatch': self._fix_album_mbid_mismatch, 'album_tag_inconsistency': self._fix_album_tag_inconsistency, 'incomplete_album': self._fix_incomplete_album, 'path_mismatch': self._fix_path_mismatch, @@ -1704,6 +1705,47 @@ class RepairWorker: except Exception as e: return {'success': False, 'error': f'Failed to remove MBID: {str(e)}'} + def _fix_album_mbid_mismatch(self, entity_type, entity_id, file_path, details): + """Rewrite the dissenting track's album MBID to match the consensus. + + The detector flagged this track because its embedded + MUSICBRAINZ_ALBUMID disagreed with the consensus across the + album's other tracks. Fix is to rewrite the dissenter's tag — + does NOT touch the other tracks (they're already in agreement). + """ + consensus_mbid = details.get('consensus_mbid') + if not consensus_mbid: + return {'success': False, 'error': 'No consensus MBID in finding details'} + if not file_path: + return {'success': False, 'error': 'No file path associated with this finding'} + + download_folder = None + if self._config_manager: + download_folder = self._config_manager.get('soulseek.download_path', '') + resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder, + config_manager=self._config_manager) + if not resolved or not os.path.exists(resolved): + return {'success': False, 'error': f'File not found: {file_path}'} + + try: + from core.repair_jobs.mbid_mismatch_detector import _write_album_mbid_to_file + ok = _write_album_mbid_to_file(resolved, consensus_mbid) + if ok: + wrong = (details.get('wrong_mbid') or '')[:8] + consensus_short = consensus_mbid[:8] + title = details.get('title', 'track') + return { + 'success': True, + 'action': 'rewrote_album_mbid', + 'message': ( + f'Updated album MBID on "{title}" ' + f'({wrong}… → {consensus_short}…)' + ), + } + return {'success': False, 'error': 'Could not write album MBID — unsupported format or write failed'} + except Exception as e: + return {'success': False, 'error': f'Failed to rewrite album MBID: {str(e)}'} + def _fix_album_tag_inconsistency(self, entity_type, entity_id, file_path, details): """Normalize inconsistent tags across all tracks in an album to the canonical (majority) value.""" inconsistencies = details.get('inconsistencies', []) @@ -2692,6 +2734,7 @@ class RepairWorker: fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch', 'missing_cover_art', 'metadata_gap', 'duplicate_tracks', 'single_album_redundant', 'mbid_mismatch', + 'album_mbid_mismatch', 'album_tag_inconsistency', 'incomplete_album', 'path_mismatch', 'missing_lossy_copy', diff --git a/database/music_database.py b/database/music_database.py index b9ffee38..b8e3e39b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1411,6 +1411,28 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_soul_id ON track_downloads (soul_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_isrc ON track_downloads (isrc)") + # Persistent MusicBrainz album → release-MBID cache. + # Backs `core/metadata/album_mbid_cache.py`. Keyed by the same + # (normalized_album_key, artist_key) shape the in-memory + # `mb_release_cache` uses, so a successful lookup remembered + # ONCE applies to every future track of the same album for + # the install's lifetime. Solves the "tracks of one album get + # different release MBIDs after cache eviction / restart" + # issue that causes Navidrome to split albums. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS mb_album_release_cache ( + normalized_album_key TEXT NOT NULL, + artist_key TEXT NOT NULL, + release_mbid TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (normalized_album_key, artist_key) + ) + """) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_mb_album_release_mbid " + "ON mb_album_release_cache (release_mbid)" + ) + # Discovery artist blacklist — artists users never want to see in discovery cursor.execute(""" CREATE TABLE IF NOT EXISTS discovery_artist_blacklist ( diff --git a/tests/metadata/test_album_mbid_cache.py b/tests/metadata/test_album_mbid_cache.py new file mode 100644 index 00000000..ba17f9fa --- /dev/null +++ b/tests/metadata/test_album_mbid_cache.py @@ -0,0 +1,207 @@ +"""Tests for ``core/metadata/album_mbid_cache.py``. + +The persistent MBID cache is the root-cause fix for "tracks of one +album get different MUSICBRAINZ_ALBUMID tags after the in-memory cache +evicts or after a server restart." Strict additive design: every +public function degrades to None / no-op on any database error so the +existing in-memory cache + MusicBrainz lookup remains the +authoritative fallback. + +These tests pin: round-trip lookup/record, idempotent re-record, +clear_all behavior, defensive None-on-empty-input, and the graceful +degradation path when the database accessor fails. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from core.metadata import album_mbid_cache +from database.music_database import get_database + + +@pytest.fixture(autouse=True) +def _wipe_cache(): + """Each test starts with a clean persistent cache so rows from + earlier tests don't leak. Wipe AFTER too so other test files + aren't affected.""" + album_mbid_cache.clear_all() + yield + album_mbid_cache.clear_all() + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_lookup_returns_none_for_missing_key() -> None: + assert album_mbid_cache.lookup('nonexistent', 'nobody') is None + + +def test_record_then_lookup_roundtrip() -> None: + assert album_mbid_cache.record('gnx', 'kendrick lamar', 'release-mbid-abc-123') is True + assert album_mbid_cache.lookup('gnx', 'kendrick lamar') == 'release-mbid-abc-123' + + +def test_record_is_idempotent() -> None: + """Re-recording the same key with the same MBID succeeds and the + lookup still returns the value. Schema uses INSERT OR REPLACE.""" + assert album_mbid_cache.record('gnx', 'kendrick lamar', 'mbid-1') is True + assert album_mbid_cache.record('gnx', 'kendrick lamar', 'mbid-1') is True + assert album_mbid_cache.lookup('gnx', 'kendrick lamar') == 'mbid-1' + + +def test_record_overwrites_with_new_mbid() -> None: + """If the same (album, artist) pair gets re-recorded with a + different MBID, the new value wins (last-write-wins). Surfaces + the case where MusicBrainz reorganized a release id.""" + album_mbid_cache.record('gnx', 'kendrick lamar', 'old-mbid') + album_mbid_cache.record('gnx', 'kendrick lamar', 'new-mbid') + assert album_mbid_cache.lookup('gnx', 'kendrick lamar') == 'new-mbid' + + +def test_clear_all_wipes_all_entries() -> None: + album_mbid_cache.record('a', 'artist1', 'mbid-1') + album_mbid_cache.record('b', 'artist2', 'mbid-2') + assert album_mbid_cache.lookup('a', 'artist1') == 'mbid-1' + assert album_mbid_cache.lookup('b', 'artist2') == 'mbid-2' + + assert album_mbid_cache.clear_all() is True + assert album_mbid_cache.lookup('a', 'artist1') is None + assert album_mbid_cache.lookup('b', 'artist2') is None + + +def test_lookup_preserves_album_artist_independence() -> None: + """Same album name across different artists must NOT collide. + Compilation albums titled 'Greatest Hits' would otherwise clobber + each other across artists.""" + album_mbid_cache.record('greatest hits', 'queen', 'queen-mbid') + album_mbid_cache.record('greatest hits', 'eminem', 'eminem-mbid') + assert album_mbid_cache.lookup('greatest hits', 'queen') == 'queen-mbid' + assert album_mbid_cache.lookup('greatest hits', 'eminem') == 'eminem-mbid' + + +# --------------------------------------------------------------------------- +# Defensive paths — must NEVER raise to caller +# --------------------------------------------------------------------------- + + +def test_lookup_returns_none_for_empty_album_key() -> None: + assert album_mbid_cache.lookup('', 'kendrick') is None + + +def test_lookup_returns_none_for_empty_artist_key() -> None: + assert album_mbid_cache.lookup('gnx', '') is None + + +def test_lookup_returns_none_for_none_inputs() -> None: + assert album_mbid_cache.lookup(None, 'kendrick') is None # type: ignore[arg-type] + assert album_mbid_cache.lookup('gnx', None) is None # type: ignore[arg-type] + + +def test_record_returns_false_for_empty_inputs() -> None: + assert album_mbid_cache.record('', 'artist', 'mbid') is False + assert album_mbid_cache.record('album', '', 'mbid') is False + assert album_mbid_cache.record('album', 'artist', '') is False + + +def test_lookup_degrades_to_none_when_db_unavailable() -> None: + """Critical defensive path: if `_get_database()` returns None + (DB module failed to load, accessor raised, etc), lookup MUST + return None — NOT raise. This is what keeps the enrichment path + working when this layer breaks.""" + with patch.object(album_mbid_cache, '_get_database', return_value=None): + assert album_mbid_cache.lookup('gnx', 'kendrick') is None + + +def test_record_degrades_to_false_when_db_unavailable() -> None: + """Same defensive contract for record.""" + with patch.object(album_mbid_cache, '_get_database', return_value=None): + assert album_mbid_cache.record('gnx', 'kendrick', 'mbid') is False + + +def test_lookup_degrades_to_none_when_query_raises() -> None: + """If the underlying SQL execute throws (locked DB, schema drift, + etc), lookup must catch it and return None. No exception escapes.""" + + class _ExplodingConn: + def cursor(self): + raise RuntimeError("simulated DB explosion") + + def close(self): + pass + + class _StubDB: + def _get_connection(self): + return _ExplodingConn() + + with patch.object(album_mbid_cache, '_get_database', return_value=_StubDB()): + assert album_mbid_cache.lookup('gnx', 'kendrick') is None + + +def test_record_degrades_to_false_when_commit_raises() -> None: + """If commit fails, record returns False — caller (enrichment path) + just doesn't get the persistent benefit, but downloads continue.""" + + class _BadCommitConn: + def cursor(self): + class _C: + def execute(self, *a, **kw): + pass + return _C() + + def commit(self): + raise RuntimeError("commit failed") + + def close(self): + pass + + class _StubDB: + def _get_connection(self): + return _BadCommitConn() + + with patch.object(album_mbid_cache, '_get_database', return_value=_StubDB()): + assert album_mbid_cache.record('gnx', 'kendrick', 'mbid') is False + + +# --------------------------------------------------------------------------- +# Schema sanity +# --------------------------------------------------------------------------- + + +def test_table_exists_after_database_init() -> None: + """The migration in `database/music_database.py` should create the + `mb_album_release_cache` table on database init.""" + db = get_database() + conn = db._get_connection() + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name='mb_album_release_cache'" + ) + row = cur.fetchone() + assert row is not None + finally: + conn.close() + + +def test_release_mbid_index_exists() -> None: + """Reverse-lookup index (find all albums for a given MBID) helps + future debug tooling. Pin its existence so a future migration + refactor doesn't quietly drop it.""" + db = get_database() + conn = db._get_connection() + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master WHERE type='index' " + "AND name='idx_mb_album_release_mbid'" + ) + row = cur.fetchone() + assert row is not None + finally: + conn.close() diff --git a/tests/metadata/test_metadata_enrichment.py b/tests/metadata/test_metadata_enrichment.py index 83644fdf..3255e8c1 100644 --- a/tests/metadata/test_metadata_enrichment.py +++ b/tests/metadata/test_metadata_enrichment.py @@ -8,6 +8,25 @@ 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: diff --git a/tests/test_album_mbid_consistency.py b/tests/test_album_mbid_consistency.py new file mode 100644 index 00000000..176d9342 --- /dev/null +++ b/tests/test_album_mbid_consistency.py @@ -0,0 +1,353 @@ +"""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 == [] diff --git a/webui/static/helper.js b/webui/static/helper.js index 041c1717..c1099d22 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3444,6 +3444,7 @@ const WHATS_NEW = { '2.4.2': [ // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.2 dev cycle' }, + { title: 'Stop Navidrome From Splitting Albums Over Inconsistent MBIDs', desc: 'discord report (samuel [KC]): tracks of the same album sometimes carry different MUSICBRAINZ_ALBUMID tags, which causes navidrome to split the album into multiple entries. two-part fix: (1) the MBID Mismatch Detector now does a second scan that groups tracks by db album, finds the consensus (most-common) album mbid, and flags dissenters — fix action rewrites the dissenter\'s tag to match. catches existing inconsistencies in your library. (2) root cause: per-track musicbrainz release lookups went through an in-memory cache that\'s capped at 4096 entries and dies on server restart, so big libraries / restarts could resolve different release ids for tracks of the same album. added a persistent sqlite-backed cache so a release mbid resolved ONCE for an album applies to every future track of that album for the install\'s lifetime. strictly additive: any failure in the persistent layer falls through to the live musicbrainz lookup exactly as before.', page: 'library' }, { title: 'Lidarr: Right Track Lands on Disk + Profile Lookup Stops Failing', desc: 'lidarr is an album-grabber — when you ask for one track it grabs the whole album, then we pick the wanted track out. old code blindly took the first imported file as the result, so any track you asked for got mistagged as track 1 of the album. now matches the wanted title against lidarr\'s track list (with punctuation-tolerant fuzzy compare) and copies only that file. also fixed a hardcoded `metadataProfileId=1` that broke artist-add on installs where someone had renamed/recreated profiles, and a polling-loop bug where the inner break never escaped the outer poll loop so completion detection was delayed. settings tooltip updated to be honest: lidarr is best for full-album grabs and effectively a no-op for playlist sync (track searches return nothing useful, hybrid mode falls through to your other sources).', page: 'settings' }, { title: 'SoundCloud as a Download Source', desc: 'discord request (toasti): some tracks (DJ mixes, sets, removed-from-spotify exclusives) only live on soundcloud. soundcloud now plugs into the existing download-source picker on settings → downloads — pick "SoundCloud Only" or include it in the hybrid order alongside soulseek / youtube / tidal / qobuz / hifi / deezer / lidarr. anonymous-only (no account needed); quality is whatever soundcloud serves anonymously, typically 128 kbps mp3 or aac depending on the upload. soundcloud doesn\'t expose lossless to anyone, so don\'t expect flac. follows the exact same wiring contract as every other download source — search dispatch, hybrid fallback, queue / cancel / clear, sidebar source label, provenance + library history all work plug-and-play.', page: 'settings' }, { title: 'Fix Qobuz Connection Not Sticking After Login', desc: 'logging in via the qobuz connect button on settings showed "connected: (active)" but underneath an error said "qobuz not authenticated...", and the dashboard indicator stayed yellow. cause: two separate qobuz client instances run side by side (one for the auth flow, one for the enrichment worker) and login only updated the first one. now the worker\'s client gets synced from config the moment login / token / logout completes, so the dashboard indicator goes green and connection-test stops yelling.', page: 'settings' },