From 8012f41ef78e3565d9e88f7ac091572a02267d39 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 21 May 2026 16:49:12 -0700
Subject: [PATCH] fix(album-completeness): block cross-artist auto-fill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reported bug: filling Jamiroquai's "Light Years" single pulled in
Gut's "Light Years" album tracks (different artist, completely
different genre — track titles like "Wound Fuck" and "Eat My Cum"
made the contamination obvious). The Album Completeness auto-fill
was the only file-copying path with a loose 0.50 SequenceMatcher
artist gate, which let unrelated candidates through whenever the
title matched well.
Two-stage defense now sits on the only album-fill code path
(_fix_incomplete_album in core/repair_worker.py):
- Stage 1 — _album_fill_target_artist_allows_track. Pre-search
gate: before doing any library lookup for a missing track,
refuse to operate if the missing track's source artist(s)
don't match the target album's artist. Compilation albums
(album_artist in {'various artists', 'various', 'soundtrack'})
bypass the gate so legitimate VA releases still work. Empty
source-artist metadata also bypasses for backward compat with
older missing-track records that don't carry per-track artist.
- Stage 2 — _album_fill_artist_names_match. Replaces the old
0.50 SequenceMatcher with an alias-aware 0.82 threshold that
uses core.matching.artist_aliases when available (handles
diacritic variants like Beyoncé/Beyonce and known stage names)
with a normalized-similarity fallback if the aliases module
isn't importable. Skipped candidates are logged at debug so a
later support ticket can show what was rejected and why.
Tests in tests/test_repair_worker_album_fill.py reproduce the
exact reported scenario: target album "Light Years" by Gut +
missing track from a Jamiroquai source → skipped with a logged
warning, no copy attempted, wishlist not poisoned. Second test
covers Stage 2 directly with a wrong-artist library candidate.
Existing test_perform_album_fill_copy_branch still passes.
Note: this fix prevents NEW cross-artist contamination via
Album Completeness. It does not clean up the data anomaly that
made Gut's library entry appear to have a "Light Years" album
in the first place — that's a separate data-quality issue worth
investigating if it recurs.
---
core/repair_worker.py | 91 +++++++++++++-
tests/test_repair_worker_album_fill.py | 161 +++++++++++++++++++++++++
webui/static/helper.js | 1 +
3 files changed, 251 insertions(+), 2 deletions(-)
diff --git a/core/repair_worker.py b/core/repair_worker.py
index 227d144a..9af2ff30 100644
--- a/core/repair_worker.py
+++ b/core/repair_worker.py
@@ -35,6 +35,64 @@ logger = get_logger("repair_worker")
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
+def _album_fill_artist_names_match(expected_artist: str, candidate_artist: str) -> bool:
+ """Strict artist gate for Album Completeness auto-fill.
+
+ Auto-fill moves/copies files into an existing album, so artist identity
+ must outrank album/title similarity. Use the alias-aware matcher when it
+ is available, then fall back to conservative normalized similarity.
+ """
+ expected = (expected_artist or '').strip()
+ candidate = (candidate_artist or '').strip()
+ if not expected or not candidate:
+ return False
+
+ try:
+ from core.matching.artist_aliases import artist_names_match
+ matched, score = artist_names_match(expected, candidate, threshold=0.82)
+ if matched:
+ return True
+ if score < 0.82:
+ return False
+ except Exception as alias_err:
+ logger.debug("artist_names_match unavailable, using fallback: %s", alias_err)
+
+ try:
+ from core.matching_engine import MusicMatchingEngine
+ engine = MusicMatchingEngine()
+ expected_norm = engine.clean_artist(expected)
+ candidate_norm = engine.clean_artist(candidate)
+ except Exception:
+ expected_norm = expected.lower()
+ candidate_norm = candidate.lower()
+
+ if not expected_norm or not candidate_norm:
+ return False
+ return expected_norm == candidate_norm or SequenceMatcher(None, expected_norm, candidate_norm).ratio() >= 0.82
+
+
+def _album_fill_target_artist_allows_track(album_artist: str, track_artists: List[str]) -> bool:
+ """Return whether a source track can be auto-filled into an album artist.
+
+ Compilation-style album artists are allowed to contain varied track
+ artists. Normal albums require at least one source track artist to match
+ the target album artist before any local candidate is considered.
+ """
+ album_artist = (album_artist or '').strip()
+ if not album_artist:
+ return False
+
+ normalized_album_artist = album_artist.lower().strip()
+ if normalized_album_artist in {'various artists', 'various', 'soundtrack'}:
+ return True
+
+ source_artists = [str(a).strip() for a in (track_artists or []) if str(a or '').strip()]
+ if not source_artists:
+ return True
+
+ return any(_album_fill_artist_names_match(album_artist, artist) for artist in source_artists)
+
+
def _split_acoustid_credit(credit: str) -> List[str]:
"""Split an AcoustID artist credit into individual contributor names.
@@ -2097,6 +2155,21 @@ class RepairWorker:
track_details.append({'track': track_name, 'status': 'skipped', 'reason': 'no track name'})
continue
+ if not _album_fill_target_artist_allows_track(artist_name, track_artists):
+ skipped_count += 1
+ logger.warning(
+ "Album auto-fill skipped '%s': source artist(s) %s do not match target album artist '%s'",
+ track_name, track_artists, artist_name,
+ )
+ track_details.append({
+ 'track': track_name,
+ 'status': 'skipped',
+ 'reason': 'source artist does not match target album artist',
+ 'source_artists': track_artists,
+ 'target_artist': artist_name,
+ })
+ continue
+
# Search library for this track
candidates = self.db.search_tracks(title=track_name, artist=artist_search, limit=20)
@@ -2117,8 +2190,22 @@ class RepairWorker:
# Artist match (more lenient)
cand_artist = getattr(cand, 'artist_name', '') or ''
- artist_sim = SequenceMatcher(None, artist_search.lower(), cand_artist.lower()).ratio()
- if artist_sim < 0.50:
+ candidate_artist_fields = [
+ cand_artist,
+ getattr(cand, 'track_artist', '') or '',
+ ]
+ expected_artist_names = track_artists or [artist_name]
+ if not any(
+ _album_fill_artist_names_match(expected, candidate)
+ for expected in expected_artist_names
+ for candidate in candidate_artist_fields
+ ):
+ logger.debug(
+ "Album auto-fill rejected candidate '%s' by '%s' for expected artist(s) %s",
+ getattr(cand, 'title', ''),
+ cand_artist,
+ expected_artist_names,
+ )
continue
# Quality gate
diff --git a/tests/test_repair_worker_album_fill.py b/tests/test_repair_worker_album_fill.py
index 57a44bcd..ee1bf28e 100644
--- a/tests/test_repair_worker_album_fill.py
+++ b/tests/test_repair_worker_album_fill.py
@@ -44,6 +44,167 @@ if "config.settings" not in sys.modules:
from core.repair_worker import RepairWorker
+def test_incomplete_album_auto_fill_skips_source_artist_mismatch(tmp_path):
+ """Album Completeness must never fill a target album with another artist."""
+ existing_path = tmp_path / "Gut" / "Light Years" / "01 - Wound Fuck.flac"
+ existing_path.parent.mkdir(parents=True)
+ existing_path.write_bytes(b"existing")
+
+ candidate_path = tmp_path / "Jamiroquai" / "Light Years.flac"
+ candidate_path.parent.mkdir(parents=True)
+ candidate_path.write_bytes(b"candidate")
+
+ class _FakeDB:
+ def __init__(self):
+ self.search_calls = []
+ self.wishlist = []
+
+ def get_tracks_by_album(self, album_id):
+ if album_id == "target-album":
+ return [
+ SimpleNamespace(
+ id="target-existing-1",
+ album_id="target-album",
+ artist_id="gut",
+ title="Wound Fuck",
+ track_number=1,
+ file_path=str(existing_path),
+ bitrate=9999,
+ ),
+ ]
+ return []
+
+ def search_tracks(self, title="", artist="", limit=50, server_source=None):
+ self.search_calls.append((title, artist, limit, server_source))
+ return [
+ SimpleNamespace(
+ id="candidate-1",
+ album_id="single-album",
+ artist_id="jamiroquai",
+ artist_name="Jamiroquai",
+ title="Light Years",
+ track_number=1,
+ file_path=str(candidate_path),
+ bitrate=9999,
+ ),
+ ]
+
+ def add_to_wishlist(self, *args, **kwargs):
+ self.wishlist.append((args, kwargs))
+
+ worker = RepairWorker.__new__(RepairWorker)
+ worker.db = _FakeDB()
+ worker.transfer_folder = str(tmp_path)
+ worker._config_manager = None
+
+ result = worker._fix_incomplete_album(
+ "album",
+ "target-album",
+ None,
+ {
+ "album_id": "target-album",
+ "album_title": "Light Years",
+ "artist": "Gut",
+ "missing_tracks": [
+ {
+ "name": "Light Years",
+ "track_number": 2,
+ "disc_number": 1,
+ "source": "spotify",
+ "source_track_id": "sp-light-years",
+ "artists": ["Jamiroquai"],
+ },
+ ],
+ },
+ )
+
+ assert result["success"] is False
+ assert result["fixed"] == 0
+ assert result["skipped"] == 1
+ assert result["details"][0]["reason"] == "source artist does not match target album artist"
+ assert worker.db.search_calls == []
+ assert worker.db.wishlist == []
+
+
+def test_incomplete_album_auto_fill_rejects_wrong_artist_candidate(tmp_path):
+ """Exact title is not enough when the candidate artist differs."""
+ existing_path = tmp_path / "album" / "01 - Existing.flac"
+ existing_path.parent.mkdir(parents=True)
+ existing_path.write_bytes(b"existing")
+ candidate_path = tmp_path / "wrong" / "02 - Light Years.flac"
+ candidate_path.parent.mkdir(parents=True)
+ candidate_path.write_bytes(b"wrong")
+
+ class _FakeDB:
+ def __init__(self):
+ self.wishlist = []
+
+ def get_tracks_by_album(self, album_id):
+ if album_id == "target-album":
+ return [
+ SimpleNamespace(
+ id="target-existing-1",
+ album_id="target-album",
+ artist_id="jamiroquai",
+ title="Existing",
+ track_number=1,
+ file_path=str(existing_path),
+ bitrate=9999,
+ ),
+ ]
+ return []
+
+ def search_tracks(self, title="", artist="", limit=50, server_source=None):
+ return [
+ SimpleNamespace(
+ id="candidate-1",
+ album_id="wrong-album",
+ artist_id="gut",
+ artist_name="Gut",
+ title="Light Years",
+ track_number=1,
+ file_path=str(candidate_path),
+ bitrate=9999,
+ ),
+ ]
+
+ def add_to_wishlist(self, *args, **kwargs):
+ self.wishlist.append((args, kwargs))
+
+ worker = RepairWorker.__new__(RepairWorker)
+ worker.db = _FakeDB()
+ worker.transfer_folder = str(tmp_path)
+ worker._config_manager = None
+
+ result = worker._fix_incomplete_album(
+ "album",
+ "target-album",
+ None,
+ {
+ "album_id": "target-album",
+ "album_title": "Light Years",
+ "artist": "Jamiroquai",
+ "spotify_album_id": "sp-album",
+ "expected_tracks": 2,
+ "missing_tracks": [
+ {
+ "name": "Light Years",
+ "track_number": 2,
+ "disc_number": 1,
+ "source": "spotify",
+ "source_track_id": "sp-light-years",
+ "artists": ["Jamiroquai"],
+ },
+ ],
+ },
+ )
+
+ assert result["success"] is True
+ assert result["fixed"] == 0
+ assert result["wishlisted"] == 1
+ assert worker.db.wishlist
+
+
def test_perform_album_fill_copy_branch_generates_track_id(tmp_path, monkeypatch):
src_path = tmp_path / "source.flac"
src_path.write_bytes(b"fake-audio")
diff --git a/webui/static/helper.js b/webui/static/helper.js
index a48529f1..0d57a8b2 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.0': [
{ unreleased: true },
+ { title: 'Fix: Album Completeness no longer cross-contaminates artists', desc: 'reported case: filling a Jamiroquai "Light Years" single brought in tracks from Gut\'s "Light Years" album — completely different artist, completely different genre. Root cause: the auto-fill artist gate was a loose 0.50 SequenceMatcher threshold that could let unrelated candidates through. Two new defenses now block this entirely. Stage 1 skips the whole track-fill operation up front if the missing track\'s source artist doesn\'t match the target album artist — compilation albums (various artists / soundtrack) still pass through. Stage 2 replaces the per-candidate 0.50 SequenceMatcher with an alias-aware 0.82 strict matcher that handles diacritics (Beyoncé/Beyonce) and known artist aliases. Both stages logged with structured warnings so future mismatches are diagnosable from the logs.' },
{ title: 'Code review refactor pass on the torrent / usenet flow', desc: 'cleanup commits before review. Lifted the shared album-pick + staging-collision helpers out of torrent.py into a new core/download_plugins/album_bundle.py module so usenet.py no longer reaches into a sibling plugin\'s private surface. Lifted the ~90-line inline album-bundle gate out of run_full_missing_tracks_process into core/downloads/album_bundle_dispatch.py with a clean pure-predicate / inject-deps design so the gate is unit-testable in isolation. Replaced the staging matcher\'s direct download_batches import with an injected get_batch_field accessor on StagingDeps. Made the 6h poll timeout configurable via download_source.album_bundle_timeout_seconds. Added atomic .tmp + rename copy so the Auto-Import worker can never observe a partial audio file during the album-bundle copy loop. 49 new tests across album_bundle, album_bundle_dispatch, and staging-provenance modules pin the contracts.' },
{ title: 'Hybrid mode skips torrent / usenet on album batches', desc: 'follow-up to the album-bundle flow. When download mode is set to Hybrid AND the batch is an album, the per-track search loop now silently strips torrent and usenet from the source chain — they\'re release-level sources that can\'t match per-track meaningfully, and the album-bundle handling only fires in single-source mode. Without the skip, hybrid + torrent-first would fire N redundant Prowlarr searches per album and rely on Auto-Import sweeping Staging to recover. Cleaner now: hybrid falls straight through to per-track-compatible sources (Soulseek / streaming) for albums, and torrent / usenet still get a shot for single-track / wishlist / basic-search use cases where per-track makes sense.' },
{ title: 'Album-bundle flow for torrent / usenet downloads', desc: 'fixes the core architectural problem with indexer-based sources. Prowlarr returns release-level torrents — searching per-track for "Luther (with SZA)" against the GNX album torrent scores near-zero and the orchestrator rejects every candidate. New gated flow: when downloading an album AND torrent or usenet is the single active source (not hybrid), SoulSync now does ONE Prowlarr search for the whole release, picks the best torrent (prefers FLAC, high seeders, reasonable size — drops single-track torrents that snuck in), hands it to your torrent / usenet client, walks the resulting audio files (extracting .zip/.rar/.tar if needed), and drops them all into the staging folder. The existing per-track staging matcher then imports each one to the library by fuzzy title match — same path as the Auto-Import flow. Gate is strictly opt-in: per-track flow is completely untouched for hybrid mode, non-album downloads, and every other source. 5 new tests cover the album picker (seeded-FLAC preference, size floor for single-track torrents, fallback when all candidates are small) and the staging path collision handler.' },