soulsync/core
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
..
artists Lift liked-artist matching to core/artists/liked_match.py 2026-04-29 17:34:28 -07:00
automation Lift /api/automations/blocks static config into core/automation/blocks.py 2026-04-27 18:31:36 -07:00
discovery Preserve artwork in quality scanner wishlist handoff 2026-04-30 21:42:16 +03:00
downloads Wire SoundCloud as a first-class download source 2026-05-03 12:54:21 -07:00
enrichment Consolidate enrichment bubble routes behind a service registry 2026-05-01 20:14:30 -07:00
imports Wire SoundCloud as a first-class download source 2026-05-03 12:54:21 -07:00
library Fix Album Completeness Auto-Fill on Docker / shared-library setups (#476) 2026-05-03 10:11:06 -07:00
metadata Fix album MBID inconsistency: detector + persistent release-MBID cache 2026-05-03 17:16:39 -07:00
playlists Lift _prepare_stream_task + playlist_explorer_build_tree to core/ 2026-04-29 11:26:07 -07:00
repair_jobs Fix album MBID inconsistency: detector + persistent release-MBID cache 2026-05-03 17:16:39 -07:00
search Extract wishlist logic into dedicated package 2026-04-28 21:17:24 +03:00
stats Lift /api/stats/* and /api/listening-stats/* into core/stats/ 2026-04-27 14:27:03 -07:00
streaming Lift _prepare_stream_task + playlist_explorer_build_tree to core/ 2026-04-29 11:26:07 -07:00
watchlist Bulk watchlist add: fall back through every source ID, not just active 2026-04-30 20:27:42 -07:00
wishlist Coerce wishlist track counts before category checks 2026-04-30 21:42:43 +03:00
workers Lift WebMetadataUpdateWorker to core/workers/metadata_update.py 2026-04-29 14:20:41 -07:00
acoustid_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
acoustid_verification.py Fix silent wrong-artist track downloads (Maduk/Tom Walker bug) 2026-04-22 10:32:55 -07:00
album_consistency.py Picard-style release preference scoring for MusicBrainz matching 2026-04-16 13:29:04 -07:00
api_call_tracker.py Make ApiCallTracker.save() atomic to prevent corrupt history files 2026-04-22 22:15:09 -07:00
artist_source_detail.py Move metadata helpers into package modules 2026-04-29 11:28:42 +03:00
artist_source_lookup.py Make artist_name Optional in find_library_artist_for_source 2026-04-22 22:15:03 -07:00
audiodb_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
audiodb_worker.py Add genre whitelist for filtering junk tags during enrichment 2026-04-18 20:23:53 -07:00
auto_import_worker.py Rename unused loop var to silence ruff B007 2026-05-02 23:18:05 -07:00
automation_engine.py Surface handler-returned errors in automation last_error 2026-04-30 15:45:28 -07:00
connection_detect.py Lift run_detection to core/connection_detect.py 2026-04-29 22:40:34 -07:00
connection_test.py Fix three SoundCloud integration gaps surfaced by smoke testing 2026-05-03 13:09:02 -07:00
database_update_worker.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
debug_info.py Make Spotify status updates event-driven 2026-05-02 22:02:01 +03:00
deezer_client.py Move metadata API into package 2026-04-29 08:10:18 +03:00
deezer_download_client.py Move metadata API into package 2026-04-29 08:10:18 +03:00
deezer_worker.py Fix Album Completeness job reporting zero findings for every album 2026-04-24 12:39:41 -07:00
discogs_client.py Move metadata API into package 2026-04-29 08:10:18 +03:00
discogs_worker.py Discogs: count rows with empty type_ as real tracks too 2026-04-25 08:27:44 -07:00
download_orchestrator.py Wire SoundCloud as a first-class download source 2026-05-03 12:54:21 -07:00
genius_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
genius_worker.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
genre_filter.py Expand default genre whitelist from 223 to 272 genres 2026-04-18 20:32:49 -07:00
hifi_client.py get hifi tags during download, without needing to go through the enrichment pipeline 2026-05-02 07:50:12 -06:00
hydrabase_client.py Add missing _get_artist_image_from_albums to HydrabaseClient 2026-03-23 17:54:21 -07:00
hydrabase_worker.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
itunes_client.py Move metadata API into package 2026-04-29 08:10:18 +03:00
itunes_worker.py Fix Album Completeness job reporting zero findings for every album 2026-04-24 12:39:41 -07:00
jellyfin_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
lastfm_client.py Add Last.fm Track Radio to Discover page 2026-04-14 23:41:36 -07:00
lastfm_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
library_reorganize.py Reorganize: fix moved-count + status/total UX issues from PR #377 review 2026-04-25 09:07:44 -07:00
lidarr_download_client.py Fix three Lidarr bugs that prevented it from being a real download source 2026-05-03 15:49:38 -07:00
listenbrainz_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
listenbrainz_manager.py Add Last.fm Track Radio to Discover page 2026-04-14 23:41:36 -07:00
listening_stats_worker.py fix: batch N+1 queries in listening stats worker 2026-04-19 15:22:25 -07:00
lyrics_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
matching_engine.py Replace more print logs with proper logger usage 2026-04-21 14:42:21 +03:00
metadata_service.py Move profile Spotify cache into registry 2026-04-29 12:36:37 +03:00
musicbrainz_client.py MusicBrainz: Resolve release-group MBIDs to a release on album click 2026-04-24 08:48:02 -07:00
musicbrainz_search.py MusicBrainz: Dedupe same-named homonyms in artist search results 2026-04-24 10:27:45 -07:00
musicbrainz_service.py Add metadata cache maintenance and health monitoring 2026-03-30 07:40:20 -07:00
musicbrainz_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
navidrome_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
personalized_playlists.py Replace all legacy metadata_service imports with canonical functions 2026-04-10 12:52:36 -07:00
plex_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
qobuz_client.py Sync Qobuz auth to enrichment worker after login 2026-05-02 14:00:04 -07:00
qobuz_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
reorganize_queue.py Reorganize queue: race + dedupe fixes from kettui review 2026-04-26 08:40:24 -07:00
reorganize_runner.py Library reorganize: FIFO queue with live status panel 2026-04-25 18:01:32 -07:00
repair_worker.py Fix album MBID inconsistency: detector + persistent release-MBID cache 2026-05-03 17:16:39 -07:00
replaygain.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
runtime_state.py PR4b: lift cancel + clear download routes to core/downloads/cancel.py 2026-04-27 21:41:35 -07:00
seasonal_discovery.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
socketio_cors.py Socket.IO CORS: handle self-review nits 2026-04-26 19:24:43 -07:00
soulid_worker.py Tighten legacy logging output 2026-04-21 18:00:31 +03:00
soulseek_client.py get hifi tags during download, without needing to go through the enrichment pipeline 2026-05-02 07:50:12 -06:00
soulsync_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
soundcloud_client.py Fix three SoundCloud integration gaps surfaced by smoke testing 2026-05-03 13:09:02 -07:00
spotify_client.py Make Spotify status updates event-driven 2026-05-02 22:02:01 +03:00
spotify_public_scraper.py Add Spotify Link tab for public playlist/album scraping without API credentials 2026-03-15 21:25:05 -07:00
spotify_worker.py fix: pause Spotify worker on non-Spotify primary + cut daily budget to 500 2026-04-29 12:46:08 -07:00
tag_writer.py Separate AcoustID file logging 2026-04-21 18:00:31 +03:00
tidal_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
tidal_download_client.py get tidal tags during download, without needing to go through the enrichment pipeline 2026-05-02 07:50:12 -06:00
tidal_worker.py fix: stop enrichment workers from re-processing rows forever 2026-04-19 15:22:24 -07:00
watchlist_scanner.py Make extract_external_ids recognize all source-tagging conventions 2026-05-02 18:26:12 -07:00
web_scan_manager.py Add SoulSync standalone library — no media server required 2026-04-17 20:34:55 -07:00
wishlist_service.py Extract wishlist logic into dedicated package 2026-04-28 21:17:24 +03:00
worker_utils.py Fix Album Completeness job reporting zero findings for every album 2026-04-24 12:39:41 -07:00
youtube_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00