soulsync/tests/imports/test_album_matching_exact_id.py
Broque Thomas e11786ee40 Auto-import matching: fix Deezer source classification + bump tolerance
User report: all 6 staging candidates failing with "Could not match
tracks to album tracklist" despite identification correctly resolving
each album. 18 properly-tagged Chris Brown F.A.M.E. tracks, 21
properly-tagged Mr. Morale tracks, etc. — every match attempt
rejected by the duration sanity gate.

Root cause: I had Deezer in `_SECONDS_DURATION_SOURCES`, assuming
Deezer's `duration` field was raw seconds (which the API returns).
But `DeezerClient.get_album_tracks` already converts seconds → ms
INTERNALLY (`'duration_ms': item.get('duration', 0) * 1000`) before
the value reaches the matcher. My helper saw `source='deezer'` →
multiplied by 1000 again → 255000 ms became 255,000,000 ms (70 hours).
Every track-file pair failed the gate by a factor of 1000×.

Diagnostic chain that got me there:
1. Added `[Album Matching] No matches: X files, Y tracks, Z
   duration-rejected, W below threshold` summary log so future "0
   matches" reports surface the rejection reason.
2. Fixed the helper's logger from `logging.getLogger(__name__)` (which
   resolves outside the soulsync handler tree → invisible in app.log)
   to `get_logger("imports.album_matching")` (under the namespace the
   file handler watches).
3. Added per-rejection-type diagnostic showing actual file vs track
   duration values + raw track keys + source.

That third diagnostic surfaced `track 'United In Grief' resolved=255000000
(raw duration_ms=255000, raw duration=None, source='deezer')` —
making the bug obvious.

Fixes:

- Moved Deezer from `_SECONDS_DURATION_SOURCES` to
  `_MS_DURATION_SOURCES`. Comment documents WHY (the client converts
  before returning) so a future reader doesn't "fix" the
  classification back the wrong way.
- Bumped `DURATION_TOLERANCE_MS` from 3000 → 10000 (3s → 10s) to
  match Picard ~7s / Beets ~10-15s / Plex ~10s industry baselines.
  3s was a defensive copy of the post-download integrity check
  threshold but that's a different problem (catching truncated
  downloads, not identifying recordings across remasters/encodings).
- `_track_duration_ms` magnitude heuristic kept as fallback for
  unknown / missing source (mocked test data without `source` field).
- Added `Match aborted` warnings at the three earlier silent return
  points in `_match_tracks` (no client, no album_data, no tracks)
  so future "Could not match" reports show WHICH step bailed.
- Added per-run diagnostic in `match_files_to_tracks` that logs the
  first duration rejection's actual values — surfaces unit mismatches
  + drift problems without spamming N×M lines per run.

Test changes:

- `test_deezer_seconds_duration_converted_to_ms` renamed +
  rewritten as `test_deezer_already_normalised_to_ms_by_client`
  to pin the actual contract (matcher receives ms from the Deezer
  client, takes as-is).
- `test_track_duration_source_aware_dispatch` updated — Deezer test
  case now uses ms input + expects ms output.
- New `test_raw_deezer_seconds_falls_back_to_magnitude_heuristic`
  pins the rare edge case where raw Deezer items WITHOUT `source`
  reach the matcher (no client conversion path) — heuristic catches
  it.

Verification:
- 179 import tests pass after changes
- Live test: all 6 user staging candidates now matching at 95-100%
  confidence
- Multi-disc Mr. Morale lands with proper Disc 1 / Disc 2 / Disc 3
  folder structure
- Picard-tagged libraries hit MBID fast paths (verified earlier)
- Tracks process in parallel via the existing scan-now thread spawn
  (next commit refactors this to a proper bounded executor)
2026-05-09 15:53:17 -07:00

479 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the ID-based fast paths + duration sanity gate added on
top of the fuzzy matcher in ``core/imports/album_matching.py``.
This is the "state-of-the-art" matching layer — bringing the auto-
import worker up to parity with what Picard / Beets / Roon do.
Algorithm (in order, each test pins one phase):
1. **MBID exact match** — file has ``MUSICBRAINZ_TRACKID`` tag, metadata
source returns the same id → instant pair, full confidence, skip
fuzzy scoring entirely.
2. **ISRC exact match** — file has ``ISRC`` tag, source returns the
same id → same fast-path, slightly lower priority than MBID
(multiple recordings can share an ISRC across remasters/regions).
3. **Duration sanity gate** — file's audio length must be within
``DURATION_TOLERANCE_MS`` of the candidate track's duration.
Defends against the cross-disc / cross-release / wrong-edit problem
the post-download integrity check used to catch only AFTER files
were already moved.
4. **Fuzzy fallback** — files with no usable IDs and no duration veto
fall through to the existing weighted scorer.
"""
from __future__ import annotations
from difflib import SequenceMatcher
from core.imports.album_matching import (
DURATION_TOLERANCE_MS,
EXACT_MATCH_CONFIDENCE,
duration_sanity_ok,
find_exact_id_matches,
match_files_to_tracks,
)
def _sim(a, b):
return SequenceMatcher(None, (a or '').lower(), (b or '').lower()).ratio()
def _qrank(ext):
ranks = {'.flac': 100, '.alac': 95, '.wav': 80, '.aac': 60,
'.ogg': 50, '.opus': 50, '.m4a': 60, '.mp3': 30}
return ranks.get((ext or '').lower(), 0)
def _tags(*, title='', artist='', album='', track=0, disc=1,
isrc='', mbid='', duration_ms=0):
return {
'title': title, 'artist': artist, 'album': album,
'track_number': track, 'disc_number': disc, 'year': '',
'isrc': isrc, 'mbid': mbid, 'duration_ms': duration_ms,
}
def _api_track(*, name='', track_number=0, disc_number=1,
isrc='', mbid='', duration_ms=0, external_ids=None):
out = {
'name': name,
'track_number': track_number,
'disc_number': disc_number,
'duration_ms': duration_ms,
'artists': [],
}
if isrc:
out['isrc'] = isrc
if mbid:
out['musicbrainz_id'] = mbid
if external_ids:
out['external_ids'] = external_ids
return out
# ---------------------------------------------------------------------------
# find_exact_id_matches — direct unit tests
# ---------------------------------------------------------------------------
def test_mbid_exact_match_pairs_file_to_track():
"""File with MBID tag matches the track carrying the same MBID,
even when title is completely wrong."""
files = ['/a/scrambled.flac']
file_tags = {
'/a/scrambled.flac': _tags(
title='Scrambled Filename', mbid='abc-123-mbid',
),
}
tracks = [
_api_track(name='Real Track Name', mbid='abc-123-mbid'),
]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
assert result['matches'][0]['file'] == '/a/scrambled.flac'
assert result['matches'][0]['match_type'] == 'mbid'
assert result['matches'][0]['confidence'] == EXACT_MATCH_CONFIDENCE
def test_isrc_exact_match_pairs_file_to_track():
files = ['/a/track.flac']
file_tags = {
'/a/track.flac': _tags(title='Foo', isrc='USRC11234567'),
}
tracks = [_api_track(name='Real', isrc='USRC11234567')]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
assert result['matches'][0]['match_type'] == 'isrc'
def test_isrc_normalization_strips_dashes_and_spaces():
"""File tag ``USRC11234567`` should match source ISRC ``US-RC1-12-34567``
— same identifier, different formatting. Picard writes compact;
some sources return hyphenated."""
files = ['/a/f.flac']
file_tags = {'/a/f.flac': _tags(isrc='USRC11234567')}
tracks = [_api_track(name='X', isrc='US-RC1-12-34567')]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
def test_mbid_takes_priority_over_isrc():
"""When both identifiers are present and they'd point at different
tracks, MBID wins. ISRC can be shared across remasters; MBID is
per-recording."""
files = ['/a/f.flac']
file_tags = {'/a/f.flac': _tags(isrc='SAME', mbid='real-mbid')}
tracks = [
_api_track(name='Wrong Recording', isrc='SAME', mbid='different-mbid'),
_api_track(name='Right Recording', mbid='real-mbid'),
]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
assert result['matches'][0]['track']['name'] == 'Right Recording'
assert result['matches'][0]['match_type'] == 'mbid'
def test_isrc_via_external_ids_dict_matches():
"""Spotify exposes ISRC under ``external_ids.isrc``, not as a
top-level field. Matcher must check both shapes."""
files = ['/a/f.flac']
file_tags = {'/a/f.flac': _tags(isrc='USRC11234567')}
tracks = [_api_track(name='X', external_ids={'isrc': 'USRC11234567'})]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
def test_no_id_match_returns_empty():
"""File and track both have IDs, but they don't match → no exact
match. (Caller falls back to fuzzy.)"""
files = ['/a/f.flac']
file_tags = {'/a/f.flac': _tags(mbid='different-id')}
tracks = [_api_track(name='X', mbid='another-id')]
result = find_exact_id_matches(files, file_tags, tracks)
assert not result['matches']
def test_each_id_match_uses_track_at_most_once():
"""Two files with the same MBID — only the first one wins. Caller
deals with the leftover (probably a duplicate/extra file)."""
files = ['/a/first.flac', '/a/second.flac']
file_tags = {
'/a/first.flac': _tags(mbid='shared'),
'/a/second.flac': _tags(mbid='shared'),
}
tracks = [_api_track(name='Track', mbid='shared')]
result = find_exact_id_matches(files, file_tags, tracks)
assert len(result['matches']) == 1
assert len(result['used_files']) == 1
# ---------------------------------------------------------------------------
# duration_sanity_ok — direct unit tests
# ---------------------------------------------------------------------------
def test_duration_within_tolerance_passes():
assert duration_sanity_ok(180_000, 180_000) is True
assert duration_sanity_ok(180_000, 181_500) is True
assert duration_sanity_ok(180_000, 180_000 - DURATION_TOLERANCE_MS) is True
def test_duration_outside_tolerance_fails():
assert duration_sanity_ok(180_000, 180_000 + DURATION_TOLERANCE_MS + 1) is False
assert duration_sanity_ok(180_000, 90_000) is False
# The Mr. Morale Auntie-Diaries-vs-Rich-Interlude case from the bug
# report: 281s file vs 103s expected — gross mismatch, must reject.
assert duration_sanity_ok(281_000, 103_000) is False
def test_duration_missing_either_side_passes():
"""Don't reject when we can't confirm. Files with no length info
(corrupt headers, etc.) defer to the fuzzy scorer."""
assert duration_sanity_ok(0, 180_000) is True
assert duration_sanity_ok(180_000, 0) is True
assert duration_sanity_ok(0, 0) is True
# ---------------------------------------------------------------------------
# match_files_to_tracks — end-to-end with the new fast paths
# ---------------------------------------------------------------------------
def test_mbid_match_short_circuits_fuzzy_scoring():
"""File with MBID + completely wrong title still matches the right
track via MBID. Demonstrates the fast-path bypassing fuzzy scoring."""
files = ['/a/file.flac']
file_tags = {
'/a/file.flac': _tags(
title='Completely Wrong Title',
artist='Wrong Artist',
track=99, disc=99,
mbid='real-mbid',
),
}
tracks = [
_api_track(name='Real Title', track_number=1, disc_number=1, mbid='real-mbid'),
]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
assert len(result['matches']) == 1
assert result['matches'][0]['match_type'] == 'mbid'
assert result['matches'][0]['confidence'] == EXACT_MATCH_CONFIDENCE
def test_id_matched_files_excluded_from_fuzzy_phase():
"""File matched in phase 1 (exact ID) shouldn't be considered in
phase 3 (fuzzy). Otherwise it could end up matched twice."""
files = ['/a/exact.flac', '/a/fuzzy.flac']
file_tags = {
'/a/exact.flac': _tags(title='Track A', mbid='mbid-a'),
'/a/fuzzy.flac': _tags(title='Track B', track=2, disc=1),
}
tracks = [
_api_track(name='Track A', track_number=1, disc_number=1, mbid='mbid-a'),
_api_track(name='Track B', track_number=2, disc_number=1),
]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
assert len(result['matches']) == 2
file_set = {m['file'] for m in result['matches']}
assert file_set == {'/a/exact.flac', '/a/fuzzy.flac'}
def test_duration_gate_rejects_wrong_disc_collision_in_fuzzy_phase():
"""The Mr. Morale bug case re-cast as a duration veto. File has
the audio length of the disc-2 track, API track is the disc-1 track
with the same number. Pre-fix: would have matched on track_number
alone. Post-fix: even after the disc-aware scoring, the duration
gate stops it."""
files = ['/a/track06.flac']
file_tags = {
'/a/track06.flac': _tags(
title='', track=6, disc=1, # wrong/missing disc tag
duration_ms=281_000, # actual audio is 4:41
),
}
tracks = [
_api_track(
name='Rich (Interlude)', track_number=6, disc_number=1,
duration_ms=103_000, # 1:43
),
]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='Mr. Morale', similarity=_sim, quality_rank=_qrank,
)
# Duration gate rejects → file unmatched (correct).
assert not result['matches']
assert result['unmatched_files'] == ['/a/track06.flac']
def test_duration_gate_within_tolerance_allows_normal_match():
"""File and track durations agree within tolerance — match proceeds
normally via fuzzy scoring."""
files = ['/a/track.flac']
file_tags = {
'/a/track.flac': _tags(
title='Father Time', track=5, disc=1, duration_ms=362_000,
),
}
tracks = [
_api_track(
name='Father Time', track_number=5, disc_number=1,
duration_ms=363_500, # 1.5s drift — within 3s tolerance
),
]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
assert len(result['matches']) == 1
def test_no_durations_anywhere_falls_through_to_fuzzy():
"""Either side missing duration → gate doesn't apply, fuzzy
scoring handles it. Catches files with corrupt audio headers."""
files = ['/a/track.flac']
file_tags = {
'/a/track.flac': _tags(
title='Father Time', track=5, disc=1, duration_ms=0,
),
}
tracks = [_api_track(name='Father Time', track_number=5, disc_number=1)]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
assert len(result['matches']) == 1
def test_deezer_already_normalised_to_ms_by_client():
"""Deezer's API returns ``duration`` in seconds — but
``DeezerClient.get_album_tracks`` converts to ``duration_ms`` (in
actual ms) before returning. The matcher classifies Deezer as an
MS source so it doesn't double-convert. Pin this so the
classification stays in sync with the client's behavior."""
files = ['/a/track.flac']
file_tags = {
'/a/track.flac': _tags(
title='Song', track=1, disc=1, duration_ms=180_000,
),
}
# Deezer-style track AS RECEIVED FROM `get_album_tracks` — already
# converted to duration_ms in actual milliseconds.
tracks = [{
'name': 'Song', 'track_number': 1, 'disc_number': 1,
'duration_ms': 180_000, 'artists': [], 'source': 'deezer',
}]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
# Source-aware dispatch keeps 180_000 as-is (don't × 1000) so it
# matches the file's 180_000 ms. Pre-fix Deezer was wrongly
# classified as a seconds source → 180_000 × 1000 = 180,000,000ms,
# gate rejected every Deezer-primary user's matches.
assert len(result['matches']) == 1
def test_track_duration_source_aware_dispatch():
"""`_track_duration_ms` routes via the `source` field — values
from sources where the CLIENT has already normalised to ms are
taken as-is."""
from core.imports.album_matching import _track_duration_ms
spotify_track = {'duration_ms': 180_000, 'source': 'spotify'}
assert _track_duration_ms(spotify_track) == 180_000
# Deezer — client converts seconds → ms before returning, so
# downstream matcher gets ms. As-is.
deezer_track = {'duration_ms': 180_000, 'source': 'deezer'}
assert _track_duration_ms(deezer_track) == 180_000
itunes_track = {'duration_ms': 200_000, 'source': 'itunes'}
assert _track_duration_ms(itunes_track) == 200_000
legacy_source = {'duration_ms': 150_000, '_source': 'spotify'}
assert _track_duration_ms(legacy_source) == 150_000
def test_raw_deezer_seconds_falls_back_to_magnitude_heuristic():
"""Edge case: if a raw Deezer item somehow reaches the matcher
WITHOUT going through the client's conversion (no `source` field,
raw `duration` key in seconds), the magnitude heuristic catches
it — value < 30000 is implausibly short for a real track and
gets × 1000."""
from core.imports.album_matching import _track_duration_ms
raw_deezer_no_source = {'duration': 180} # no source field
assert _track_duration_ms(raw_deezer_no_source) == 180_000
def test_track_duration_short_real_track_not_misconverted_with_known_source():
"""An actual sub-30s track on Spotify (intro/interlude/skit) —
duration_ms is genuinely small. Source-aware dispatch must take
spotify_ms_value as-is and NOT × 1000 it via the magnitude
heuristic. Pre-fix this would have been hit by:
20_000 ms (a 20-second intro) > 0 and < 30000 → converted to
20_000_000 ms = 5.5 hours. Wrong.
Post-fix: source='spotify' is in MS list, value taken as-is.
"""
from core.imports.album_matching import _track_duration_ms
short_intro = {'duration_ms': 20_000, 'source': 'spotify'}
assert _track_duration_ms(short_intro) == 20_000
def test_track_duration_unknown_source_falls_back_to_heuristic():
"""No source field — apply the legacy magnitude heuristic so
tests / mocks without source still work. < 30000 = seconds."""
from core.imports.album_matching import _track_duration_ms
no_source_seconds = {'duration': 180} # heuristic: < 30000 → seconds
assert _track_duration_ms(no_source_seconds) == 180_000
no_source_ms = {'duration_ms': 200_000} # heuristic: > 30000 → ms
assert _track_duration_ms(no_source_ms) == 200_000
def test_album_track_entry_propagates_isrc_and_mbid_from_source():
"""Production-path guard: the metadata-source layer
(`_build_album_track_entry`) must propagate ISRC + MBID from the
raw track responses, otherwise the matcher's fast paths never fire
in production even though they pass in unit tests.
Spotify shape: ``external_ids.isrc`` (nested dict).
iTunes shape: top-level ``isrc``.
"""
from core.metadata.album_tracks import _build_album_track_entry
spotify_shape = {
'id': 'spotify-track',
'name': 'Test',
'external_ids': {'isrc': 'USRC11234567', 'mbid': 'mb-123'},
'duration_ms': 200_000,
'track_number': 1,
'disc_number': 1,
}
entry = _build_album_track_entry(spotify_shape, {'name': 'Album'}, 'spotify')
assert entry['isrc'] == 'USRC11234567'
assert entry['musicbrainz_id'] == 'mb-123'
itunes_shape = {
'id': 'itunes-track',
'name': 'Test',
'isrc': 'USRC11234567',
'duration_ms': 200_000,
'track_number': 1,
'disc_number': 1,
}
entry = _build_album_track_entry(itunes_shape, {'name': 'Album'}, 'itunes')
assert entry['isrc'] == 'USRC11234567'
# No identifiers — entry has empty strings (not None / missing keys),
# so the matcher's `_track_identifier()` returns empty cleanly.
bare_shape = {
'id': 'bare', 'name': 'Test',
'duration_ms': 200_000, 'track_number': 1, 'disc_number': 1,
}
entry = _build_album_track_entry(bare_shape, {'name': 'Album'}, 'unknown')
assert entry['isrc'] == ''
assert entry['musicbrainz_id'] == ''
def test_picard_tagged_library_full_album_via_mbid_only():
"""Realistic Picard-tagged library: every file has MBID, no useful
title-disc-track agreement needed. Whole album should pair via the
fast path on the first phase."""
files = [f'/a/picard_{i}.flac' for i in range(1, 11)]
file_tags = {
f: _tags(
title=f'mangled name {i}', # title doesn't help
track=99 - i, disc=99, # position info is wrong
mbid=f'mbid-{i}',
)
for i, f in enumerate(files, start=1)
}
tracks = [
_api_track(
name=f'Real Track {i}',
track_number=i, disc_number=1,
mbid=f'mbid-{i}',
)
for i in range(1, 11)
]
result = match_files_to_tracks(
files, file_tags, tracks,
target_album='', similarity=_sim, quality_rank=_qrank,
)
assert len(result['matches']) == 10
# All matched via MBID, full confidence
for m in result['matches']:
assert m['match_type'] == 'mbid'
assert m['confidence'] == EXACT_MATCH_CONFIDENCE