soulsync/tests/matching/test_acoustid_candidates.py
Broque Thomas 9cc09118bf AcoustID scanner: multi-candidate match + duration guard + multi-value retag
Closes #587. Three coordinated fixes per codex's diagnosis. AcoustID
verification gate left intact — these fixes target the upstream
scanner false-positive surface plus a separate retag-path gap.

Bug 1 — scanner used recordings[0] as authoritative

`core/repair_jobs/acoustid_scanner.py:_scan_file` only checked the
top fingerprint match's metadata. AcoustID often returns multiple
recordings per fingerprint (sample collisions, multi-MB-record
cases) and the wrong-credited recording can outrank the right-
credited one. Foxxify case 2 (Nana / Nana): top match credited the
wrong artist while a lower-ranked candidate matched the user's
expected metadata exactly.

Lifted the verifier's all-candidates check to a shared pure helper
`core/matching/acoustid_candidates.py:find_matching_recording`. Both
verifier and scanner can now ask "given these candidates, does ANY
of them match expected (title, artist)?" with the same contract.
Scanner suppresses the finding when any candidate matches.

Bug 2 — no duration check guards against fingerprint hash collisions

Foxxify case 3: 17-minute mashup edit fingerprinted to a 5-minute
late-70s Japanese hiphop track (different songs, fingerprint hash
collision on a sampled section). Scanner had no signal to detect
this and would have recommended retagging the 17-min file as the
5-min track.

`duration_mismatches_strongly` in the same helper module flags drifts
beyond max(60s, 35%). Scanner now skips findings when the candidate's
duration disagrees strongly with the file's expected duration. Loaded
duration via the existing tracks SQL (added `t.duration` to the
SELECT). Returns False when either side is unknown — no behavior
change for older rows without duration data.

Bug 3 — scanner retag bypassed multi-value ARTISTS tag setting

`core/repair_worker.py:_fix_wrong_song` called `write_tags_to_file`
with single-string artist updates. The writer only wrote TPE1
(single string) and never read the user's
`metadata_enhancement.tags.write_multi_artist` config. Multi-value
ARTISTS tags got stripped on every retag, contradicting the
post-download enrichment pipeline's behavior.

Per codex's pick (option B over routing through enhance_file_metadata),
extended `write_tags_to_file` with an optional `artists_list`
parameter. Each format-specific writer respects the config flag the
same way enrichment.py does:
- ID3: TPE1 stays as joined display string + TXXX:Artists multi-value
- Vorbis/Opus/FLAC: `artist` display string + `artists` multi-value key
- MP4: \xa9ART as list when on, single string when off

Scanner retag derives the per-artist list by splitting AcoustID's
credit through the existing `split_artist_credit` helper (same
separators the matching layer already uses).

Backward compatible: callers that don't pass `artists_list` get the
exact same single-string write as before. No regression for the
write_artist_image button or any other tag_writer caller.

15 tests on the candidate helper + duration guard.
13 tests on the tag_writer multi-value path (write/skip/single/
no-list cases for FLAC + the config-gate helper).
4 new scanner regression tests pinning lower-ranked candidate
suppression, no-suppression when no candidate matches, duration
mismatch skip, no-skip when duration matches.

Existing scanner tests updated for the new 11-column SQL select
(added duration column to fake schema + test row tuples).

Full suite: 3097 passed. Ruff clean.
2026-05-14 14:09:38 -07:00

201 lines
8.1 KiB
Python

"""Tests for the shared AcoustID candidate-matching helper.
Issue #587 / Foxxify report — scanner used to treat ``recordings[0]``
as authoritative, so when AcoustID returned multiple candidates and
the top one was the wrong-credited recording (different MB entry
under the same fingerprint), the scanner created a false-positive
"Wrong download" finding even though a lower-ranked candidate matched
the expected metadata exactly.
"""
from __future__ import annotations
from difflib import SequenceMatcher
import pytest
from core.matching.acoustid_candidates import (
duration_mismatches_strongly,
find_matching_recording,
)
def _ratio_sim(a: str, b: str) -> float:
"""Reasonable test similarity that handles non-trivial differences."""
if not a or not b:
return 0.0
return SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio()
# ──────────────────────────────────────────────────────────────────────
# find_matching_recording
# ──────────────────────────────────────────────────────────────────────
def test_top_recording_matches_returns_immediately():
recordings = [
{'title': 'Nana', 'artist': 'Geoxor'},
{'title': 'Nana', 'artist': 'Edward Vesala Trio'},
]
result, t_sim, a_sim = find_matching_recording(
recordings, 'Nana', 'Geoxor', similarity=_ratio_sim,
)
assert result == {'title': 'Nana', 'artist': 'Geoxor'}
assert t_sim == 1.0
assert a_sim == 1.0
def test_falls_through_to_lower_ranked_match_for_foxxify_nana_case():
"""Reporter case 2: top AcoustID candidate is 'Nana' by 'Edward
Vesala Trio' (97% fingerprint), but the LOWER-ranked candidate
is the expected 'Nana' by 'Geoxor'. Pre-fix scanner saw only [0]
and flagged. Post-fix returns the matching candidate."""
recordings = [
{'title': 'Nana', 'artist': 'Edward Vesala Trio'}, # AcoustID's top match
{'title': 'Nana', 'artist': 'Geoxor'}, # the actual right one
]
result, _, _ = find_matching_recording(
recordings, 'Nana', 'Geoxor', similarity=_ratio_sim,
)
assert result == {'title': 'Nana', 'artist': 'Geoxor'}
def test_no_match_returns_none_with_best_seen_sims():
"""When no candidate passes thresholds, return the best-seen sims
so callers can log the closest near-miss in the finding."""
recordings = [
{'title': 'Different Song', 'artist': 'Different Artist'},
{'title': 'Sort Of Close', 'artist': 'Different Artist'},
]
result, t_sim, a_sim = find_matching_recording(
recordings, 'Different', 'AnotherArtist',
similarity=_ratio_sim,
title_threshold=0.95,
artist_threshold=0.95,
)
assert result is None
# Best seen — even though no candidate passed the threshold
assert t_sim > 0.0
assert a_sim > 0.0
def test_skips_recordings_missing_title_or_artist():
recordings = [
{'title': None, 'artist': 'Geoxor'},
{'title': 'Nana', 'artist': ''},
{'title': 'Nana', 'artist': 'Geoxor'},
]
result, _, _ = find_matching_recording(
recordings, 'Nana', 'Geoxor', similarity=_ratio_sim,
)
assert result == {'title': 'Nana', 'artist': 'Geoxor'}
def test_skips_non_dict_entries():
recordings = [None, 'string', {'title': 'Nana', 'artist': 'Geoxor'}]
result, _, _ = find_matching_recording(
recordings, 'Nana', 'Geoxor', similarity=_ratio_sim,
)
assert result == {'title': 'Nana', 'artist': 'Geoxor'}
def test_empty_inputs_return_none():
assert find_matching_recording([], 'X', 'Y')[0] is None
assert find_matching_recording([{'title': 'X', 'artist': 'Y'}], '', 'Y')[0] is None
assert find_matching_recording([{'title': 'X', 'artist': 'Y'}], 'X', '')[0] is None
def test_separate_artist_similarity_function_is_honored():
"""Verifier passes alias-aware comparison via artist_similarity.
Make sure it's used instead of the generic similarity."""
recordings = [{'title': 'Track', 'artist': '澤野弘之'}]
def alias_aware(expected, actual):
# Pretend our alias chain bridges Hiroyuki Sawano ↔ 澤野弘之
if expected == 'Hiroyuki Sawano' and actual == '澤野弘之':
return 1.0
return 0.0
result, _, a_sim = find_matching_recording(
recordings, 'Track', 'Hiroyuki Sawano',
similarity=_ratio_sim,
artist_similarity=alias_aware,
)
assert result is not None
assert a_sim == 1.0
def test_skip_predicate_drops_unwanted_candidates():
"""Verifier uses skip_predicate to drop wrong-version recordings
(instrumental vs vocal, etc.)."""
recordings = [
{'title': 'Track (Instrumental)', 'artist': 'X'},
{'title': 'Track', 'artist': 'X'},
]
result, _, _ = find_matching_recording(
recordings, 'Track', 'X',
similarity=_ratio_sim,
skip_predicate=lambda r: 'instrumental' in (r.get('title') or '').lower(),
)
assert result == {'title': 'Track', 'artist': 'X'}
def test_title_threshold_can_be_lowered_for_loose_matching():
recordings = [{'title': 'Sort Of Close', 'artist': 'Right Artist'}]
# With strict default threshold this fails
result_strict, _, _ = find_matching_recording(
recordings, 'Different', 'Right Artist', similarity=_ratio_sim,
)
assert result_strict is None
# With a permissive threshold the artist match alone wouldn't help —
# title sim must also pass.
result_loose, _, _ = find_matching_recording(
recordings, 'Different', 'Right Artist',
similarity=_ratio_sim, title_threshold=0.0,
)
assert result_loose is not None
# ──────────────────────────────────────────────────────────────────────
# duration_mismatches_strongly — guard against fingerprint collisions
# ──────────────────────────────────────────────────────────────────────
def test_durations_within_tolerance_pass():
# 3-minute track, 1-second drift — well within tolerance
assert duration_mismatches_strongly(180, 181) is False
# 3-minute vs 4-minute — within the 60s absolute tolerance
assert duration_mismatches_strongly(180, 240) is False
def test_drift_above_absolute_floor_flags():
# 3-minute expected, 5-minute candidate (120s drift > 63s threshold)
assert duration_mismatches_strongly(180, 300) is True
def test_relative_tolerance_scales_with_long_tracks():
# 30-minute expected vs 12-minute candidate (1080s vs 720s) —
# 18-minute drift > 35% of 30min = 10.5min → mismatch
assert duration_mismatches_strongly(1800, 720) is True
# 30-minute expected vs 28-minute candidate — 2min drift = under
# max(60s, 35%*30min) = max(60, 630) = 630s → still safe
assert duration_mismatches_strongly(1800, 1680) is False
def test_reporter_17min_mashup_vs_5min_track_flagged():
"""Foxxify's 17min mashup edit vs 5min late-70s Japanese hiphop —
fingerprint collision. Duration guard should mark this suspicious."""
assert duration_mismatches_strongly(17 * 60, 5 * 60) is True
def test_unknown_duration_returns_false_no_behavior_change():
"""When either side is missing duration, don't change behavior."""
assert duration_mismatches_strongly(None, 300) is False
assert duration_mismatches_strongly(180, None) is False
assert duration_mismatches_strongly(0, 300) is False
assert duration_mismatches_strongly(180, 0) is False
assert duration_mismatches_strongly(-5, 300) is False
def test_string_or_int_durations_handled():
# Defensive — coerce numeric types
assert duration_mismatches_strongly(180.5, 181.0) is False
assert duration_mismatches_strongly(int(180), int(300)) is True