/
/
1"""Tests for mediaitem compare helper functions."""
2
3import pytest
4from music_assistant_models import media_items
5from music_assistant_models.enums import ExternalID
6
7from music_assistant.helpers import compare
8
9
10def _album(
11 item_id: str = "1",
12 provider: str = "test1",
13 name: str = "Album A",
14 version: str = "",
15 year: int | None = None,
16 external_ids: set[tuple[ExternalID, str]] | None = None,
17) -> media_items.Album:
18 """Build a minimal Album for evidence comparisons."""
19 return media_items.Album(
20 item_id=item_id,
21 provider=provider,
22 name=name,
23 version=version,
24 year=year,
25 external_ids=external_ids or set(),
26 artists=media_items.UniqueList(
27 [
28 media_items.Artist(
29 item_id="artist",
30 provider=provider,
31 name="Artist A",
32 provider_mappings={
33 media_items.ProviderMapping(
34 item_id="artist", provider_domain="test", provider_instance=provider
35 )
36 },
37 )
38 ]
39 ),
40 provider_mappings={
41 media_items.ProviderMapping(
42 item_id=item_id, provider_domain="test", provider_instance=provider
43 )
44 },
45 )
46
47
48def _track(
49 item_id: str,
50 disc_number: int = 1,
51 track_number: int = 1,
52 name: str = "Track",
53 version: str = "",
54 duration: int = 200,
55 isrc: str | None = None,
56) -> media_items.Track:
57 """Build a minimal Track for album-track fingerprint comparisons."""
58 return media_items.Track(
59 item_id=item_id,
60 provider="test1",
61 name=name,
62 version=version,
63 duration=duration,
64 disc_number=disc_number,
65 track_number=track_number,
66 external_ids={(ExternalID.ISRC, isrc)} if isrc else set(),
67 provider_mappings={
68 media_items.ProviderMapping(
69 item_id=item_id, provider_domain="test", provider_instance="test1"
70 )
71 },
72 )
73
74
75def _tracklist(
76 count: int, *, isrc_prefix: str = "USRC17607", duration: int = 200
77) -> list[media_items.Track]:
78 """Build an ordered list of distinct tracks sharing a common ISRC/duration scheme."""
79 return [
80 _track(
81 item_id=str(number),
82 track_number=number,
83 name=f"Track {number}",
84 duration=duration,
85 isrc=f"{isrc_prefix}{number:03d}",
86 )
87 for number in range(1, count + 1)
88 ]
89
90
91def test_compare_version() -> None:
92 """Test the version compare helper."""
93 assert compare.compare_version("Remaster", "remaster") is True
94 assert compare.compare_version("Remastered", "remaster") is True
95 assert compare.compare_version("Remaster", "") is False
96 assert compare.compare_version("Remaster", "Remix") is False
97 assert compare.compare_version("", "Deluxe") is False
98 assert compare.compare_version("", "Live") is False
99 assert compare.compare_version("Live", "live") is True
100 assert compare.compare_version("Live", "live version") is True
101 assert compare.compare_version("Live version", "live") is True
102 assert compare.compare_version("Deluxe Edition", "Deluxe") is True
103 assert compare.compare_version("Deluxe Karaoke Edition", "Deluxe") is False
104 assert compare.compare_version("Deluxe Karaoke Edition", "Karaoke") is False
105 assert compare.compare_version("Deluxe Edition", "Edition Deluxe") is True
106 assert compare.compare_version("", "Karaoke Version") is False
107 assert compare.compare_version("Karaoke", "Karaoke Version") is True
108 assert compare.compare_version("Remaster", "Remaster Edition Deluxe") is False
109 assert compare.compare_version("Remastered Version", "Deluxe Version") is False
110 assert compare.compare_version("2011 Remaster", "Remastered 2011") is True
111 assert compare.compare_version("", "Album Version") is True
112 assert compare.compare_version("Deluxe 2022 Remaster", "2022 Remaster") is False
113
114
115def test_compare_artist() -> None:
116 """Test artist comparison."""
117 artist_a = media_items.Artist(
118 item_id="1",
119 provider="test1",
120 name="Artist A",
121 provider_mappings={
122 media_items.ProviderMapping(
123 item_id="1", provider_domain="test", provider_instance="test1"
124 )
125 },
126 )
127 artist_b = media_items.Artist(
128 item_id="1",
129 provider="test2",
130 name="Artist A",
131 provider_mappings={
132 media_items.ProviderMapping(
133 item_id="2", provider_domain="test", provider_instance="test2"
134 )
135 },
136 )
137 # test match on name match
138 assert compare.compare_artist(artist_a, artist_b) is True
139 # test match on name mismatch
140 artist_b.name = "Artist B"
141 assert compare.compare_artist(artist_a, artist_b) is False
142 # test on exact item_id match
143 artist_b.item_id = artist_a.item_id
144 artist_b.provider = artist_a.provider
145 assert compare.compare_artist(artist_a, artist_b) is True
146 # test on external id match
147 artist_b.name = "Artist B"
148 artist_b.item_id = "2"
149 artist_b.provider = "test2"
150 artist_a.external_ids = {(ExternalID.MB_ARTIST, "123")}
151 artist_b.external_ids = artist_a.external_ids
152 assert compare.compare_artist(artist_a, artist_b) is True
153 # test on external id mismatch
154 artist_b.name = artist_a.name
155 artist_b.external_ids = {(ExternalID.MB_ARTIST, "1234")}
156 assert compare.compare_artist(artist_a, artist_b) is False
157 # test on external id mismatch while name matches
158 artist_a = media_items.Artist(
159 item_id="1",
160 provider="test1",
161 name="Artist A",
162 external_ids={(ExternalID.MB_ARTIST, "123")},
163 provider_mappings={
164 media_items.ProviderMapping(
165 item_id="1", provider_domain="test", provider_instance="test1"
166 )
167 },
168 )
169 artist_b = media_items.Artist(
170 item_id="1",
171 provider="test2",
172 name="Artist A",
173 external_ids={(ExternalID.MB_ARTIST, "abc")},
174 provider_mappings={
175 media_items.ProviderMapping(
176 item_id="2", provider_domain="test", provider_instance="test2"
177 )
178 },
179 )
180 assert compare.compare_artist(artist_a, artist_b) is False
181
182
183def test_compare_album() -> None:
184 """Test album comparison."""
185 album_a = media_items.Album(
186 item_id="1",
187 provider="test1",
188 name="Album A",
189 provider_mappings={
190 media_items.ProviderMapping(
191 item_id="1", provider_domain="test", provider_instance="test1"
192 )
193 },
194 artists=media_items.UniqueList(
195 [
196 media_items.Artist(
197 item_id="1",
198 provider="test1",
199 name="Artist A",
200 provider_mappings={
201 media_items.ProviderMapping(
202 item_id="1", provider_domain="test", provider_instance="test1"
203 )
204 },
205 )
206 ]
207 ),
208 )
209 album_b = media_items.Album(
210 item_id="1",
211 provider="test2",
212 name="Album A",
213 provider_mappings={
214 media_items.ProviderMapping(
215 item_id="2", provider_domain="test", provider_instance="test2"
216 )
217 },
218 artists=media_items.UniqueList(
219 [
220 media_items.Artist(
221 item_id="1",
222 provider="test1",
223 name="Artist A",
224 provider_mappings={
225 media_items.ProviderMapping(
226 item_id="1", provider_domain="test", provider_instance="test1"
227 )
228 },
229 )
230 ]
231 ),
232 )
233 # test match on name match
234 assert compare.compare_album(album_a, album_b) is True
235 # test match on name mismatch
236 album_b.name = "Album B"
237 assert compare.compare_album(album_a, album_b) is False
238 # test on version mismatch
239 album_b.name = album_a.name
240 album_b.version = "Deluxe"
241 assert compare.compare_album(album_a, album_b) is False
242 album_b.version = "Remix"
243 assert compare.compare_album(album_a, album_b) is False
244 # test on version match
245 album_b.name = album_a.name
246 album_a.version = "Deluxe"
247 album_b.version = "Deluxe Edition"
248 assert compare.compare_album(album_a, album_b) is True
249 # test on exact item_id match
250 album_b.item_id = album_a.item_id
251 album_b.provider = album_a.provider
252 assert compare.compare_album(album_a, album_b) is True
253 # test on external id match
254 album_b.name = "Album B"
255 album_b.item_id = "2"
256 album_b.provider = "test2"
257 album_a.external_ids = {(ExternalID.MB_ALBUM, "123")}
258 album_b.external_ids = album_a.external_ids
259 assert compare.compare_album(album_a, album_b) is True
260 # test on external id mismatch
261 album_b.name = album_a.name
262 album_b.external_ids = {(ExternalID.MB_ALBUM, "1234")}
263 assert compare.compare_album(album_a, album_b) is False
264 album_a.external_ids = set()
265 album_b.external_ids = set()
266 # fail on year mismatch
267 album_b.external_ids = set()
268 album_a.year = 2021
269 album_b.year = 2020
270 assert compare.compare_album(album_a, album_b) is False
271 # pass on year match
272 album_b.year = 2021
273 assert compare.compare_album(album_a, album_b) is True
274 # fail on artist mismatch
275 album_a.artists = media_items.UniqueList(
276 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
277 )
278 album_b.artists = media_items.UniqueList(
279 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
280 )
281 assert compare.compare_album(album_a, album_b) is False
282 # pass on partial artist match (if first artist matches)
283 album_a.artists = media_items.UniqueList(
284 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
285 )
286 album_b.artists = media_items.UniqueList(
287 [
288 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
289 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
290 ]
291 )
292 assert compare.compare_album(album_a, album_b) is True
293 # fail on partial artist match in strict mode
294 album_b.artists = media_items.UniqueList(
295 [
296 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
297 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
298 ]
299 )
300 assert compare.compare_album(album_a, album_b) is False
301 # partial artist match is allowed in non-strict mode
302 assert compare.compare_album(album_a, album_b, False) is True
303
304
305def test_compare_album_barcode_requires_corroboration() -> None:
306 """A shared retail barcode only overrides year when album identity also agrees."""
307 artist_a = media_items.Artist(
308 item_id="1",
309 provider="test1",
310 name="Artist A",
311 provider_mappings={
312 media_items.ProviderMapping(
313 item_id="1", provider_domain="test", provider_instance="test1"
314 )
315 },
316 )
317 artist_b = media_items.Artist(
318 item_id="2",
319 provider="test2",
320 name="Artist A",
321 provider_mappings={
322 media_items.ProviderMapping(
323 item_id="2", provider_domain="test", provider_instance="test2"
324 )
325 },
326 )
327 barcode_a = (ExternalID.BARCODE, "000724354283857")
328 barcode_b = (ExternalID.BARCODE, "0724354283857")
329 album_a = media_items.Album(
330 item_id="1",
331 provider="test1",
332 name="#1",
333 year=2001,
334 artists=media_items.UniqueList([artist_a]),
335 external_ids={barcode_a},
336 provider_mappings={
337 media_items.ProviderMapping(
338 item_id="1", provider_domain="test", provider_instance="test1"
339 )
340 },
341 )
342 album_b = media_items.Album(
343 item_id="2",
344 provider="test2",
345 name="#1",
346 year=2002,
347 artists=media_items.UniqueList([artist_b]),
348 external_ids={barcode_b},
349 provider_mappings={
350 media_items.ProviderMapping(
351 item_id="2", provider_domain="test", provider_instance="test2"
352 )
353 },
354 )
355
356 assert compare.compare_album(album_a, album_b) is True
357
358 album_b.name = "Different Album"
359 assert compare.compare_album(album_a, album_b) is False
360 album_b.name = album_a.name
361 album_b.artists[0].name = "Different Artist"
362 assert compare.compare_album(album_a, album_b) is False
363
364
365def test_compare_album_evidence_barcode_never_matches_unrelated_titles() -> None:
366 """A shared canonical barcode alone must never match unrelated title/artist data."""
367 barcode = {(ExternalID.BARCODE, "0724354283857")}
368 album_a = _album(name="Album A", external_ids=barcode)
369 album_b = _album(item_id="2", provider="test2", name="Completely Different Album", year=1999)
370 album_b.external_ids = barcode
371
372 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
373 assert compare.compare_album(album_a, album_b) is False
374
375
376def test_compare_album_evidence_name_hyphen_spacing_drift_matches() -> None:
377 """Punctuation/spacing drift around a title's hyphenation does not block a match."""
378 album_a = _album(name="All Change - - EP")
379 album_b = _album(item_id="2", provider="test2", name="All Change - EP")
380
381 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
382 assert compare.compare_album(album_a, album_b) is True
383
384
385def test_compare_album_evidence_name_accent_and_apostrophe_variants_match() -> None:
386 """Diacritic and apostrophe drift in an otherwise identical title does not block a match."""
387 album_a = _album(name="Am\u00e9lie Soundtrack")
388 album_b = _album(item_id="2", provider="test2", name="Amelie Soundtrack")
389 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
390
391 album_a = _album(name="Guns N' Roses Live")
392 album_b = _album(item_id="2", provider="test2", name="Guns N Roses Live")
393 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
394
395
396def test_compare_album_evidence_unrelated_punctuation_only_titles_stay_distinct() -> None:
397 """Two different titles that both normalize to nothing must not be treated as equal."""
398 album_a = _album(name="...")
399 album_b = _album(item_id="2", provider="test2", name="!!!")
400
401 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
402 assert compare.compare_album(album_a, album_b) is False
403
404
405def test_compare_album_evidence_mb_releasegroup_not_sufficient_alone() -> None:
406 """A shared MusicBrainz release-group is family evidence only, never sufficient identity."""
407 releasegroup = {(ExternalID.MB_RELEASEGROUP, "11111111-1111-1111-1111-111111111111")}
408 original = _album(name="Album A", version="Original Mix", external_ids=releasegroup)
409 remaster = _album(item_id="2", provider="test2", name="Album A", version="Live")
410 remaster.external_ids = releasegroup
411
412 # a genuine edition conflict (studio mix vs. live) must not auto-merge on releasegroup alone
413 assert compare.compare_album_evidence(original, remaster) == compare.AlbumMatchEvidence.NO_MATCH
414 assert compare.compare_album(original, remaster) is False
415
416
417def test_compare_album_evidence_ambiguous_version_without_tracks_is_insufficient() -> None:
418 """An ambiguous subset/superset edition wording without tracklists stays undecided."""
419 base_item = _album(version="2022 Remaster")
420 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
421
422 assert (
423 compare.compare_album_evidence(base_item, compare_item)
424 == compare.AlbumMatchEvidence.INSUFFICIENT
425 )
426 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
427 assert compare.compare_album(base_item, compare_item) is False
428
429
430def test_compare_album_evidence_subset_wording_with_recording_conflict_is_no_match() -> None:
431 """A subset edition wording that adds a recording-altering qualifier must not merge."""
432 base_item = _album(version="Deluxe")
433 compare_item = _album(item_id="2", provider="test2", version="Deluxe Karaoke Edition")
434
435 assert (
436 compare.compare_album_evidence(base_item, compare_item)
437 == compare.AlbumMatchEvidence.NO_MATCH
438 )
439 assert compare.compare_album(base_item, compare_item) is False
440
441
442def test_compare_album_evidence_recording_conflict_word_anywhere_is_no_match() -> None:
443 """A recording-changing qualifier stays NO_MATCH even when it's shared, not differing."""
444 # "live" is shared by both sides here (not the token that differs), but a bare
445 # "Live" tag next to a specific "Live at <venue>" tag is still not safe to merge
446 base_item = _album(version="Live")
447 compare_item = _album(item_id="2", provider="test2", version="Live at Wembley")
448
449 assert (
450 compare.compare_album_evidence(base_item, compare_item)
451 == compare.AlbumMatchEvidence.NO_MATCH
452 )
453 assert compare.compare_album(base_item, compare_item) is False
454
455
456def test_compare_album_evidence_equivalent_location_wording_matches() -> None:
457 """Reordered wording that differs only by a harmless connector word ('at') still matches."""
458 base_item = _album(version="Live at Wembley")
459 compare_item = _album(item_id="2", provider="test2", version="Wembley Live")
460
461 assert (
462 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
463 )
464 assert compare.compare_album(base_item, compare_item) is True
465
466
467def test_compare_album_evidence_blank_vs_remaster_version_is_insufficient() -> None:
468 """A blank version next to a tagged remaster is undecided, not a proven conflict."""
469 base_item = _album(version="")
470 compare_item = _album(item_id="2", provider="test2", version="Remaster")
471
472 assert (
473 compare.compare_album_evidence(base_item, compare_item)
474 == compare.AlbumMatchEvidence.INSUFFICIENT
475 )
476 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
477 assert compare.compare_album(base_item, compare_item) is False
478
479
480def test_compare_album_evidence_blank_vs_remaster_resolves_with_fingerprint() -> None:
481 """A blank-vs-remaster version gap is resolved once track fingerprints are supplied."""
482 base_item = _album(version="")
483 compare_item = _album(item_id="2", provider="test2", version="Remaster")
484 matching_tracks = _tracklist(10)
485
486 assert (
487 compare.compare_album_evidence(
488 base_item, compare_item, base_tracks=matching_tracks, compare_tracks=matching_tracks
489 )
490 == compare.AlbumMatchEvidence.MATCH
491 )
492
493 conflicting_tracks = _tracklist(10, isrc_prefix="USRC28718")
494 assert (
495 compare.compare_album_evidence(
496 base_item,
497 compare_item,
498 base_tracks=matching_tracks,
499 compare_tracks=conflicting_tracks,
500 )
501 == compare.AlbumMatchEvidence.NO_MATCH
502 )
503
504
505@pytest.mark.parametrize(
506 "conflict_version",
507 ["Live", "Remix", "Karaoke Edition", "Instrumental", "Acoustic Version", "Demo", "Cover"],
508)
509def test_compare_album_evidence_blank_vs_recording_conflict_is_no_match(
510 conflict_version: str,
511) -> None:
512 """
513 A blank version next to a recording-changing qualifier is a proven conflict.
514
515 Unlike a blank version next to plain packaging wording (remaster, deluxe, ...), a
516 missing version tag can never explain away a recording-altering qualifier on the
517 other side, so this must not be left for a tracklist to resolve.
518 """
519 base_item = _album(version="")
520 compare_item = _album(item_id="2", provider="test2", version=conflict_version)
521
522 assert (
523 compare.compare_album_evidence(base_item, compare_item)
524 == compare.AlbumMatchEvidence.NO_MATCH
525 )
526 assert compare.compare_album(base_item, compare_item) is False
527
528
529@pytest.mark.parametrize("packaging_version", ["Remaster", "Deluxe Edition", "Anniversary Edition"])
530def test_compare_album_evidence_blank_vs_packaging_edition_is_insufficient(
531 packaging_version: str,
532) -> None:
533 """A blank version next to plain packaging wording stays undecided, not a proven conflict."""
534 base_item = _album(version="")
535 compare_item = _album(item_id="2", provider="test2", version=packaging_version)
536
537 assert (
538 compare.compare_album_evidence(base_item, compare_item)
539 == compare.AlbumMatchEvidence.INSUFFICIENT
540 )
541 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
542 assert compare.compare_album(base_item, compare_item) is False
543
544
545def test_compare_album_evidence_resolves_with_matching_fingerprint() -> None:
546 """Ambiguous edition wording resolves to MATCH once track fingerprints agree."""
547 base_item = _album(version="2022 Remaster")
548 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
549 base_tracks = _tracklist(14)
550 compare_tracks = _tracklist(14)
551
552 assert (
553 compare.compare_album_evidence(
554 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
555 )
556 == compare.AlbumMatchEvidence.MATCH
557 )
558
559
560def test_compare_album_evidence_resolves_with_conflicting_fingerprint() -> None:
561 """Ambiguous edition wording resolves to NO_MATCH once track fingerprints disagree."""
562 base_item = _album(version="2022 Remaster")
563 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
564 base_tracks = _tracklist(14, isrc_prefix="USRC17607")
565 compare_tracks = _tracklist(14, isrc_prefix="USRC28718")
566
567 assert (
568 compare.compare_album_evidence(
569 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
570 )
571 == compare.AlbumMatchEvidence.NO_MATCH
572 )
573
574
575def test_compare_album_evidence_ordinary_and_deluxe_track_counts_stay_distinct() -> None:
576 """An ordinary 8-track album and a 14-track edition must not be merged via fingerprints."""
577 base_item = _album(version="2022 Remaster")
578 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
579 base_tracks = _tracklist(8)
580 compare_tracks = _tracklist(14)
581
582 assert (
583 compare.compare_album_evidence(
584 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
585 )
586 == compare.AlbumMatchEvidence.NO_MATCH
587 )
588
589
590def test_compare_album_evidence_fingerprint_overrides_matching_metadata() -> None:
591 """A conflicting tracklist overrides an otherwise nominally-matching album."""
592 base_item = _album(version="", year=2020)
593 compare_item = _album(item_id="2", provider="test2", version="", year=2020)
594 base_tracks = _tracklist(8)
595 compare_tracks = _tracklist(14)
596
597 assert (
598 compare.compare_album_evidence(
599 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
600 )
601 == compare.AlbumMatchEvidence.NO_MATCH
602 )
603
604
605def test_compare_album_track_fingerprint_matching_isrc_and_duration() -> None:
606 """Equal ISRC, title and duration at every position is a confident match."""
607 base_tracks = _tracklist(3)
608 compare_tracks = _tracklist(3)
609
610 assert (
611 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
612 == compare.AlbumMatchEvidence.MATCH
613 )
614
615
616def test_compare_album_track_fingerprint_shared_isrc_without_duration_is_insufficient() -> None:
617 """A shared ISRC alone cannot confirm a match if neither side has a duration."""
618 base_tracks = [_track("1", track_number=1, isrc="USRC17607839", duration=0)]
619 compare_tracks = [_track("2", track_number=1, isrc="USRC17607839", duration=0)]
620
621 assert (
622 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
623 == compare.AlbumMatchEvidence.INSUFFICIENT
624 )
625
626
627def test_compare_album_track_fingerprint_conflicting_isrc() -> None:
628 """Conflicting ISRCs at the same position indicate a different recording/remaster."""
629 base_tracks = [_track("1", track_number=1, isrc="USRC17607839")]
630 compare_tracks = [_track("2", track_number=1, isrc="USRC28718001")]
631
632 assert (
633 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
634 == compare.AlbumMatchEvidence.NO_MATCH
635 )
636
637
638def test_compare_album_track_fingerprint_invalid_isrc_falls_back_to_title_duration() -> None:
639 """A structurally invalid ISRC is ignored, not treated as identity evidence."""
640 base_tracks = [_track("1", track_number=1, name="Track One", duration=200, isrc="NOTANISRC")]
641 matching_compare_tracks = [
642 _track("2", track_number=1, name="Track One", duration=200, isrc="ALSOINVALID")
643 ]
644
645 # both sides tag an (invalid) ISRC, but neither is structurally valid, so the
646 # comparison falls back to title/duration and still finds a match
647 assert (
648 compare.compare_album_track_fingerprint(base_tracks, matching_compare_tracks)
649 == compare.AlbumMatchEvidence.MATCH
650 )
651
652 # a genuine title conflict is still caught once the invalid ISRCs are ignored
653 conflicting_compare_tracks = [
654 _track("2", track_number=1, name="Different Track", duration=200, isrc="ALSOINVALID")
655 ]
656 assert (
657 compare.compare_album_track_fingerprint(base_tracks, conflicting_compare_tracks)
658 == compare.AlbumMatchEvidence.NO_MATCH
659 )
660
661
662def test_compare_album_track_fingerprint_title_duration_fallback() -> None:
663 """Without ISRCs, matching normalized title/version and a tight duration match."""
664 base_tracks = [_track("1", track_number=1, name="Track One", duration=200)]
665 compare_tracks = [_track("2", track_number=1, name="Track One", duration=201)]
666
667 assert (
668 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
669 == compare.AlbumMatchEvidence.MATCH
670 )
671 # a genuine duration conflict without ISRCs cannot be treated as the same recording
672 compare_tracks = [_track("2", track_number=1, name="Track One", duration=260)]
673 assert (
674 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
675 == compare.AlbumMatchEvidence.NO_MATCH
676 )
677
678
679def test_compare_album_track_fingerprint_sparse_tracks_are_insufficient() -> None:
680 """Tracks with no ISRC, title, or duration cannot support a confident decision."""
681 base_tracks = [_track("1", track_number=1, name="", duration=0)]
682 compare_tracks = [_track("2", track_number=1, name="", duration=0)]
683
684 assert (
685 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
686 == compare.AlbumMatchEvidence.INSUFFICIENT
687 )
688 # no tracklists supplied at all is equally undecided
689 assert (
690 compare.compare_album_track_fingerprint(None, None)
691 == compare.AlbumMatchEvidence.INSUFFICIENT
692 )
693 assert (
694 compare.compare_album_track_fingerprint([], []) == compare.AlbumMatchEvidence.INSUFFICIENT
695 )
696
697
698def test_compare_album_track_fingerprint_unknown_disc_layout_vs_multi_disc_is_insufficient() -> (
699 None
700):
701 """An unknown (no disc numbers reported at all) layout can't be shape-compared."""
702 # a provider that never reports disc numbers: assumed single disc by omission
703 base_tracks = [_track(str(n), disc_number=0, track_number=n) for n in range(1, 15)]
704 # the other side is a genuine 2-disc release (8 + 6 tracks)
705 compare_tracks = [_track(f"d1-{n}", disc_number=1, track_number=n) for n in range(1, 9)] + [
706 _track(f"d2-{n}", disc_number=2, track_number=n) for n in range(1, 7)
707 ]
708
709 # assuming disc 1 for the unknown side would falsely look like a shape conflict
710 # (or, worse, a false match): neither is safe, so this must stay undecided
711 assert (
712 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
713 == compare.AlbumMatchEvidence.INSUFFICIENT
714 )
715
716
717def test_compare_external_ids_checks_all_unique_values() -> None:
718 """One mismatching unique identifier does not hide another matching value."""
719 base_ids = {
720 (ExternalID.MB_ALBUM, "11111111-1111-1111-1111-111111111111"),
721 (ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222"),
722 }
723 compare_ids = {(ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222")}
724
725 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.MB_ALBUM) is True
726
727
728def test_compare_track() -> None: # noqa: PLR0915
729 """Test track comparison."""
730 track_a = media_items.Track(
731 item_id="1",
732 provider="test1",
733 name="Track A",
734 provider_mappings={
735 media_items.ProviderMapping(
736 item_id="1", provider_domain="test", provider_instance="test1"
737 )
738 },
739 artists=media_items.UniqueList(
740 [
741 media_items.Artist(
742 item_id="1",
743 provider="test1",
744 name="Artist A",
745 provider_mappings={
746 media_items.ProviderMapping(
747 item_id="1", provider_domain="test", provider_instance="test1"
748 )
749 },
750 )
751 ]
752 ),
753 )
754 track_b = media_items.Track(
755 item_id="1",
756 provider="test2",
757 name="Track A",
758 provider_mappings={
759 media_items.ProviderMapping(
760 item_id="2", provider_domain="test", provider_instance="test2"
761 )
762 },
763 artists=media_items.UniqueList(
764 [
765 media_items.Artist(
766 item_id="1",
767 provider="test1",
768 name="Artist A",
769 provider_mappings={
770 media_items.ProviderMapping(
771 item_id="1", provider_domain="test", provider_instance="test1"
772 )
773 },
774 )
775 ]
776 ),
777 )
778 # test match on name match
779 assert compare.compare_track(track_a, track_b) is True
780 # test match on name mismatch
781 track_b.name = "Track B"
782 assert compare.compare_track(track_a, track_b) is False
783 # test on version mismatch
784 track_b.name = track_a.name
785 track_b.version = "Deluxe"
786 assert compare.compare_track(track_a, track_b) is False
787 track_b.version = "Remix"
788 assert compare.compare_track(track_a, track_b) is False
789 # test on version mismatch
790 track_b.name = track_a.name
791 track_a.version = ""
792 track_b.version = "Remaster"
793 assert compare.compare_track(track_a, track_b) is False
794 track_b.version = "Remix"
795 assert compare.compare_track(track_a, track_b) is False
796 # test on version match
797 track_b.name = track_a.name
798 track_a.version = "Deluxe"
799 track_b.version = "Deluxe Edition"
800 assert compare.compare_track(track_a, track_b) is True
801 # test on exact item_id match
802 track_b.item_id = track_a.item_id
803 track_b.provider = track_a.provider
804 assert compare.compare_track(track_a, track_b) is True
805 # test on external id match
806 track_b.name = "Track B"
807 track_b.item_id = "2"
808 track_b.provider = "test2"
809 track_a.external_ids = {(ExternalID.MB_RECORDING, "123")}
810 track_b.external_ids = track_a.external_ids
811 assert compare.compare_track(track_a, track_b) is True
812 # test on external id mismatch
813 track_b.name = track_a.name
814 track_b.external_ids = {(ExternalID.MB_RECORDING, "1234")}
815 assert compare.compare_track(track_a, track_b) is False
816 track_a.external_ids = set()
817 track_b.external_ids = set()
818 # fail on artist mismatch
819 track_a.artists = media_items.UniqueList(
820 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
821 )
822 track_b.artists = media_items.UniqueList(
823 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
824 )
825 assert compare.compare_track(track_a, track_b) is False
826 # pass on partial artist match (if first artist matches)
827 track_a.artists = media_items.UniqueList(
828 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
829 )
830 track_b.artists = media_items.UniqueList(
831 [
832 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
833 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
834 ]
835 )
836 assert compare.compare_track(track_a, track_b) is True
837 # fail on partial artist match in strict mode
838 track_b.artists = media_items.UniqueList(
839 [
840 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
841 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
842 ]
843 )
844 assert compare.compare_track(track_a, track_b) is False
845 # partial artist match is allowed in non-strict mode
846 assert compare.compare_track(track_a, track_b, False) is True
847 track_b.artists = track_a.artists
848 # fail on album mismatch
849 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
850 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
851 assert compare.compare_track(track_a, track_b) is False
852 # pass on exact album(track) match (regardless duration)
853 track_b.album = track_a.album
854 track_a.disc_number = 1
855 track_a.track_number = 1
856 track_b.disc_number = track_a.disc_number
857 track_b.track_number = track_a.track_number
858 track_a.duration = 300
859 track_b.duration = 310
860 assert compare.compare_track(track_a, track_b) is True
861 # pass on album(track) mismatch
862 track_b.album = track_a.album
863 track_a.disc_number = 1
864 track_a.track_number = 1
865 track_b.disc_number = track_a.disc_number
866 track_b.track_number = 2
867 track_b.duration = track_a.duration
868 assert compare.compare_track(track_a, track_b) is False
869 # test special case - ISRC match but MusicBrainz ID mismatch
870 # this can happen for some classical music albums
871 track_a.external_ids = {
872 (ExternalID.ISRC, "123"),
873 (ExternalID.MB_RECORDING, "abc"),
874 }
875 track_b.external_ids = {
876 (ExternalID.ISRC, "123"),
877 (ExternalID.MB_RECORDING, "abcd"),
878 }
879 assert compare.compare_track(track_a, track_b) is False
880 # test multi-disc: same album, same external IDs, different disc numbers should NOT match
881 track_a.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
882 track_b.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
883 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
884 track_b.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
885 track_a.disc_number = 1
886 track_b.disc_number = 2
887 track_a.track_number = 3
888 track_b.track_number = 3
889 assert compare.compare_track(track_a, track_b) is False
890 # same disc number should still match via external ID
891 track_b.disc_number = 1
892 assert compare.compare_track(track_a, track_b) is True
893 # different disc but different albums should still match via external ID
894 track_b.disc_number = 2
895 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
896 assert compare.compare_track(track_a, track_b) is True
897
898
899def test_compare_strings_case_insensitive_fuzzy() -> None:
900 """Test that non-strict fuzzy matching is fully case-insensitive."""
901 # These differ slightly ("Feat." vs "FT.") so create_safe_string won't match,
902 # falling through to SequenceMatcher which must compare both strings lowered.
903 assert compare.compare_strings("Track Feat. John", "TRACK FT. JOHN", strict=False) is True
904
905
906def test_compare_radio() -> None:
907 """Test the radio compare helper."""
908
909 def _radio(
910 item_id: str, provider: str, name: str, *, is_dynamic: bool = False
911 ) -> media_items.Radio:
912 return media_items.Radio(
913 item_id=item_id,
914 provider=provider,
915 name=name,
916 is_dynamic=is_dynamic,
917 provider_mappings={
918 media_items.ProviderMapping(
919 item_id=item_id, provider_domain=provider, provider_instance=provider
920 )
921 },
922 )
923
924 live_a = _radio("a", "tunein", "Chill Vibes")
925 live_b = _radio("b", "radiobrowser", "Chill Vibes")
926 # a live station is matched across providers on its name
927 assert compare.compare_radio(live_a, live_b) is True
928
929 station = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
930 # a dynamic station only exists on its own provider, so the name is not enough
931 assert compare.compare_radio(station, live_a) is False
932 assert compare.compare_radio(live_a, station) is False
933 # ... but it is still recognised as itself
934 same = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
935 assert compare.compare_radio(station, same) is True
936