fix(album-completeness): block cross-artist auto-fill

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.
This commit is contained in:
Broque Thomas 2026-05-21 16:49:12 -07:00
parent 5ab210a2d7
commit 8012f41ef7
3 changed files with 251 additions and 2 deletions

View file

@ -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

View file

@ -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")

View file

@ -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. <strong>Stage 1</strong> 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. <strong>Stage 2</strong> 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.' },