Cin-pass on the MBID/ISRC fast-paths + duration-gate work.
Three small but real gaps closed.
Gap 1 — Real-file tag reader integration test
(tests/imports/test_auto_import_tag_reader_real_files.py, 6 tests):
The matcher unit tests use dict fixtures, which prove the algorithm
handles the right shapes once tags are read. They DON'T prove the tag
reader itself extracts the right values from real files. Mutagen's
easy-mode key normalisation (across FLAC / MP3 / M4A) is the exact
spot a future mutagen version could silently drift and break the
fast paths in production while every unit test stays green.
These tests write real FLAC files via mutagen (using the same
`_make_minimal_flac` pattern from `test_album_mbid_consistency.py`)
and assert `_read_file_tags` extracts:
- Picard's `MUSICBRAINZ_TRACKID` (lowercase normalisation in reader)
- `ISRC` (uppercase normalisation in reader; matcher strips
formatting at compare time)
- "track/total" parsing (TRACKNUMBER='5/12' → 5)
- Duration via `audio.info.length` from synthesised STREAMINFO
- Graceful empty-default return for tagless files
- Graceful empty-default return for invalid audio (not a crash)
Acknowledged gap (carried forward): MP3 + M4A integration coverage
not added — mutagen docs say easy-mode normalisation is identical
across all three formats, but only FLAC is pinned here. Followup
candidate.
Gap 2 — Source-aware duration dispatch
(core/imports/album_matching.py, 4 tests in test_album_matching_exact_id.py):
The previous `_track_duration_ms` helper used a magnitude heuristic
("anything below 30000 is seconds, convert × 1000") to decide
whether a track's duration was in seconds or ms. That worked for
typical tracks but had a real edge case: an actual sub-30-second
Spotify track (intros, interludes, skits) would be detected as
seconds and converted to 8.5 hours, breaking the duration sanity
gate.
Replaced with deterministic source-aware dispatch:
- Spotify / iTunes / Qobuz / HiFi / Hydrabase → ms (canonical)
- Deezer / Discogs / MusicBrainz → seconds, × 1000
- Tidal classified as ms (album-tracks endpoint convention; flagged
in code comment as needing real-world verification — defensive
if wrong)
- Magnitude heuristic kept as fallback for unknown / missing source
(mocked test data without source field)
Tests pin all four paths: confirmed-ms source, confirmed-seconds
source, unknown source falls back to heuristic, and the regression
case (sub-30s real track on a known-ms source — must not be
× 1000-converted).
Gap 3 — Cross-disc consolation rationale
(tests/imports/test_album_matching_helper.py, 1 test):
The `CROSS_DISC_POSITION_WEIGHT = 0.05` magic number had no test
proving it was load-bearing. Anyone could have set it to 0 thinking
"strict matching is better" without realising it would silently
break a real scenario.
New test (`test_cross_disc_consolation_is_load_bearing_for_imperfect_titles`)
constructs the exact case the consolation exists for: file has the
right title spelling but the metadata source returns a slightly-
different version (e.g. "Auntie Diaries" file vs "Auntie Diaries
(Remix)" track), AND the file's disc tag is wrong while the track
number agrees. Title sim ~0.78 × 0.45 = ~0.35 (below
MATCH_THRESHOLD 0.4). Without the 5% consolation → file goes
unmatched. With it → ~0.40, just clears.
The test doesn't justify "why 0.05 specifically" — that's still a
tuned knob, not a measured value. But it forces a deliberate
decision if someone wants to drop it: failing this test gives them
the "you broke imperfect-title cross-disc matching" message
explicitly.
Verification:
- 10 new tests across 3 files, all pass
- 35 album-matching tests total now (including pre-existing 17 +
18 fast-path)
- Full suite: 2321 passed, 1 pre-existing flaky timing test
(`test_watchdog_warns_about_stuck_workers` — passes in isolation,
fails only in full-suite runs, unrelated to this PR)
- Ruff clean
- All changes still scoped to import flow — download flow byte-
identical (verified by grep on every changed file)
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""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_seconds_duration_converted_to_ms():
|
||
"""Deezer's API returns ``duration`` in seconds, not ms. The matcher
|
||
must convert before applying the tolerance check — otherwise a
|
||
180-second track looks like a 180-millisecond track and fails the
|
||
sanity gate against any real file."""
|
||
files = ['/a/track.flac']
|
||
file_tags = {
|
||
'/a/track.flac': _tags(
|
||
title='Song', track=1, disc=1, duration_ms=180_000,
|
||
),
|
||
}
|
||
# Deezer-style track — duration is 180 (seconds)
|
||
tracks = [{
|
||
'name': 'Song', 'track_number': 1, 'disc_number': 1,
|
||
'duration': 180, 'artists': [], 'source': 'deezer',
|
||
}]
|
||
result = match_files_to_tracks(
|
||
files, file_tags, tracks,
|
||
target_album='', similarity=_sim, quality_rank=_qrank,
|
||
)
|
||
# 180 seconds → 180_000 ms → matches file's 180_000 ms within tolerance
|
||
assert len(result['matches']) == 1
|
||
|
||
|
||
def test_track_duration_source_aware_dispatch():
|
||
"""`_track_duration_ms` must route via the `source` field — not
|
||
fall back to magnitude heuristic — so providers with edge-case
|
||
durations (sub-30s real tracks, intros, interludes) don't trigger
|
||
false unit conversion."""
|
||
from core.imports.album_matching import _track_duration_ms
|
||
|
||
# Spotify-style — explicit ms field, treat as-is
|
||
spotify_track = {'duration_ms': 180_000, 'source': 'spotify'}
|
||
assert _track_duration_ms(spotify_track) == 180_000
|
||
|
||
# Deezer-style — `duration` field in seconds, convert
|
||
deezer_track = {'duration': 180, 'source': 'deezer'}
|
||
assert _track_duration_ms(deezer_track) == 180_000
|
||
|
||
# iTunes — duration_ms (their internal field is `trackTimeMillis`
|
||
# but `_build_album_track_entry` normalises to `duration_ms`)
|
||
itunes_track = {'duration_ms': 200_000, 'source': 'itunes'}
|
||
assert _track_duration_ms(itunes_track) == 200_000
|
||
|
||
# Source via _source alias also works (normalize_import_context legacy)
|
||
legacy_source = {'duration_ms': 150_000, '_source': 'spotify'}
|
||
assert _track_duration_ms(legacy_source) == 150_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
|