/
/
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_album_tracks_have_positions() -> None:
718 """A trustworthy disc/track layout is recognized, an ambiguous one is not."""
719 assert compare.album_tracks_have_positions(_tracklist(10)) is True
720 assert compare.album_tracks_have_positions(None) is False
721 assert compare.album_tracks_have_positions([]) is False
722 # a missing track number makes the layout untrustworthy
723 assert compare.album_tracks_have_positions([_track("1", track_number=0)]) is False
724 # a missing disc number is treated as unknown, not silently assumed to be disc 1
725 assert compare.album_tracks_have_positions([_track("1", disc_number=0)]) is False
726 # a duplicate position cannot be trusted either
727 duplicate = [_track("1", track_number=1), _track("2", track_number=1)]
728 assert compare.album_tracks_have_positions(duplicate) is False
729
730
731def test_compare_external_ids_checks_all_unique_values() -> None:
732 """One mismatching unique identifier does not hide another matching value."""
733 base_ids = {
734 (ExternalID.MB_ALBUM, "11111111-1111-1111-1111-111111111111"),
735 (ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222"),
736 }
737 compare_ids = {(ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222")}
738
739 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.MB_ALBUM) is True
740
741
742def test_compare_track() -> None: # noqa: PLR0915
743 """Test track comparison."""
744 track_a = media_items.Track(
745 item_id="1",
746 provider="test1",
747 name="Track A",
748 provider_mappings={
749 media_items.ProviderMapping(
750 item_id="1", provider_domain="test", provider_instance="test1"
751 )
752 },
753 artists=media_items.UniqueList(
754 [
755 media_items.Artist(
756 item_id="1",
757 provider="test1",
758 name="Artist A",
759 provider_mappings={
760 media_items.ProviderMapping(
761 item_id="1", provider_domain="test", provider_instance="test1"
762 )
763 },
764 )
765 ]
766 ),
767 )
768 track_b = media_items.Track(
769 item_id="1",
770 provider="test2",
771 name="Track A",
772 provider_mappings={
773 media_items.ProviderMapping(
774 item_id="2", provider_domain="test", provider_instance="test2"
775 )
776 },
777 artists=media_items.UniqueList(
778 [
779 media_items.Artist(
780 item_id="1",
781 provider="test1",
782 name="Artist A",
783 provider_mappings={
784 media_items.ProviderMapping(
785 item_id="1", provider_domain="test", provider_instance="test1"
786 )
787 },
788 )
789 ]
790 ),
791 )
792 # test match on name match
793 assert compare.compare_track(track_a, track_b) is True
794 # test match on name mismatch
795 track_b.name = "Track B"
796 assert compare.compare_track(track_a, track_b) is False
797 # test on version mismatch
798 track_b.name = track_a.name
799 track_b.version = "Deluxe"
800 assert compare.compare_track(track_a, track_b) is False
801 track_b.version = "Remix"
802 assert compare.compare_track(track_a, track_b) is False
803 # test on version mismatch
804 track_b.name = track_a.name
805 track_a.version = ""
806 track_b.version = "Remaster"
807 assert compare.compare_track(track_a, track_b) is False
808 track_b.version = "Remix"
809 assert compare.compare_track(track_a, track_b) is False
810 # test on version match
811 track_b.name = track_a.name
812 track_a.version = "Deluxe"
813 track_b.version = "Deluxe Edition"
814 assert compare.compare_track(track_a, track_b) is True
815 # test on exact item_id match
816 track_b.item_id = track_a.item_id
817 track_b.provider = track_a.provider
818 assert compare.compare_track(track_a, track_b) is True
819 # test on external id match
820 track_b.name = "Track B"
821 track_b.item_id = "2"
822 track_b.provider = "test2"
823 track_a.external_ids = {(ExternalID.MB_RECORDING, "123")}
824 track_b.external_ids = track_a.external_ids
825 assert compare.compare_track(track_a, track_b) is True
826 # test on external id mismatch
827 track_b.name = track_a.name
828 track_b.external_ids = {(ExternalID.MB_RECORDING, "1234")}
829 assert compare.compare_track(track_a, track_b) is False
830 track_a.external_ids = set()
831 track_b.external_ids = set()
832 # fail on artist mismatch
833 track_a.artists = media_items.UniqueList(
834 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
835 )
836 track_b.artists = media_items.UniqueList(
837 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
838 )
839 assert compare.compare_track(track_a, track_b) is False
840 # pass on partial artist match (if first artist matches)
841 track_a.artists = media_items.UniqueList(
842 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
843 )
844 track_b.artists = media_items.UniqueList(
845 [
846 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
847 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
848 ]
849 )
850 assert compare.compare_track(track_a, track_b) is True
851 # fail on partial artist match in strict mode
852 track_b.artists = media_items.UniqueList(
853 [
854 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
855 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
856 ]
857 )
858 assert compare.compare_track(track_a, track_b) is False
859 # partial artist match is allowed in non-strict mode
860 assert compare.compare_track(track_a, track_b, False) is True
861 track_b.artists = track_a.artists
862 # fail on album mismatch
863 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
864 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
865 assert compare.compare_track(track_a, track_b) is False
866 # pass on exact album(track) match (regardless duration)
867 track_b.album = track_a.album
868 track_a.disc_number = 1
869 track_a.track_number = 1
870 track_b.disc_number = track_a.disc_number
871 track_b.track_number = track_a.track_number
872 track_a.duration = 300
873 track_b.duration = 310
874 assert compare.compare_track(track_a, track_b) is True
875 # pass on album(track) mismatch
876 track_b.album = track_a.album
877 track_a.disc_number = 1
878 track_a.track_number = 1
879 track_b.disc_number = track_a.disc_number
880 track_b.track_number = 2
881 track_b.duration = track_a.duration
882 assert compare.compare_track(track_a, track_b) is False
883 # test special case - ISRC match but MusicBrainz ID mismatch
884 # this can happen for some classical music albums
885 track_a.external_ids = {
886 (ExternalID.ISRC, "123"),
887 (ExternalID.MB_RECORDING, "abc"),
888 }
889 track_b.external_ids = {
890 (ExternalID.ISRC, "123"),
891 (ExternalID.MB_RECORDING, "abcd"),
892 }
893 assert compare.compare_track(track_a, track_b) is False
894 # test multi-disc: same album, same external IDs, different disc numbers should NOT match
895 track_a.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
896 track_b.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
897 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
898 track_b.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
899 track_a.disc_number = 1
900 track_b.disc_number = 2
901 track_a.track_number = 3
902 track_b.track_number = 3
903 assert compare.compare_track(track_a, track_b) is False
904 # same disc number should still match via external ID
905 track_b.disc_number = 1
906 assert compare.compare_track(track_a, track_b) is True
907 # different disc but different albums should still match via external ID
908 track_b.disc_number = 2
909 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
910 assert compare.compare_track(track_a, track_b) is True
911
912
913def test_compare_strings_case_insensitive_fuzzy() -> None:
914 """Test that non-strict fuzzy matching is fully case-insensitive."""
915 # These differ slightly ("Feat." vs "FT.") so create_safe_string won't match,
916 # falling through to SequenceMatcher which must compare both strings lowered.
917 assert compare.compare_strings("Track Feat. John", "TRACK FT. JOHN", strict=False) is True
918
919
920def test_compare_radio() -> None:
921 """Test the radio compare helper."""
922
923 def _radio(
924 item_id: str, provider: str, name: str, *, is_dynamic: bool = False
925 ) -> media_items.Radio:
926 return media_items.Radio(
927 item_id=item_id,
928 provider=provider,
929 name=name,
930 is_dynamic=is_dynamic,
931 provider_mappings={
932 media_items.ProviderMapping(
933 item_id=item_id, provider_domain=provider, provider_instance=provider
934 )
935 },
936 )
937
938 live_a = _radio("a", "tunein", "Chill Vibes")
939 live_b = _radio("b", "radiobrowser", "Chill Vibes")
940 # a live station is matched across providers on its name
941 assert compare.compare_radio(live_a, live_b) is True
942
943 station = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
944 # a dynamic station only exists on its own provider, so the name is not enough
945 assert compare.compare_radio(station, live_a) is False
946 assert compare.compare_radio(live_a, station) is False
947 # ... but it is still recognised as itself
948 same = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
949 assert compare.compare_radio(station, same) is True
950