/
/
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_version_deduplicates_repeated_tokens() -> None:
116 """Repeated wording inside one version string does not block a match."""
117 assert (
118 compare.compare_version("Deluxe [2022 Remaster] 2022 Remaster", "Deluxe 2022 Remaster")
119 is True
120 )
121
122
123def test_compare_version_ignores_hi_res_wording() -> None:
124 """Quality-only wording (hi-res) is ignored wherever it appears in a version."""
125 assert compare.compare_version("Remastered", "Remastered Hi-Res Version") is True
126 assert compare.compare_version("Hi-Res Version", "") is True
127 assert compare.compare_version("Hi-Res", "Remastered") is False
128
129
130def test_compare_artist() -> None:
131 """Test artist comparison."""
132 artist_a = media_items.Artist(
133 item_id="1",
134 provider="test1",
135 name="Artist A",
136 provider_mappings={
137 media_items.ProviderMapping(
138 item_id="1", provider_domain="test", provider_instance="test1"
139 )
140 },
141 )
142 artist_b = media_items.Artist(
143 item_id="1",
144 provider="test2",
145 name="Artist A",
146 provider_mappings={
147 media_items.ProviderMapping(
148 item_id="2", provider_domain="test", provider_instance="test2"
149 )
150 },
151 )
152 # test match on name match
153 assert compare.compare_artist(artist_a, artist_b) is True
154 # test match on name mismatch
155 artist_b.name = "Artist B"
156 assert compare.compare_artist(artist_a, artist_b) is False
157 # test on exact item_id match
158 artist_b.item_id = artist_a.item_id
159 artist_b.provider = artist_a.provider
160 assert compare.compare_artist(artist_a, artist_b) is True
161 # test on external id match
162 artist_b.name = "Artist B"
163 artist_b.item_id = "2"
164 artist_b.provider = "test2"
165 artist_a.external_ids = {(ExternalID.MB_ARTIST, "123")}
166 artist_b.external_ids = artist_a.external_ids
167 assert compare.compare_artist(artist_a, artist_b) is True
168 # test on external id mismatch
169 artist_b.name = artist_a.name
170 artist_b.external_ids = {(ExternalID.MB_ARTIST, "1234")}
171 assert compare.compare_artist(artist_a, artist_b) is False
172 # test on external id mismatch while name matches
173 artist_a = media_items.Artist(
174 item_id="1",
175 provider="test1",
176 name="Artist A",
177 external_ids={(ExternalID.MB_ARTIST, "123")},
178 provider_mappings={
179 media_items.ProviderMapping(
180 item_id="1", provider_domain="test", provider_instance="test1"
181 )
182 },
183 )
184 artist_b = media_items.Artist(
185 item_id="1",
186 provider="test2",
187 name="Artist A",
188 external_ids={(ExternalID.MB_ARTIST, "abc")},
189 provider_mappings={
190 media_items.ProviderMapping(
191 item_id="2", provider_domain="test", provider_instance="test2"
192 )
193 },
194 )
195 assert compare.compare_artist(artist_a, artist_b) is False
196
197
198def test_compare_album() -> None:
199 """Test album comparison."""
200 album_a = media_items.Album(
201 item_id="1",
202 provider="test1",
203 name="Album A",
204 provider_mappings={
205 media_items.ProviderMapping(
206 item_id="1", provider_domain="test", provider_instance="test1"
207 )
208 },
209 artists=media_items.UniqueList(
210 [
211 media_items.Artist(
212 item_id="1",
213 provider="test1",
214 name="Artist A",
215 provider_mappings={
216 media_items.ProviderMapping(
217 item_id="1", provider_domain="test", provider_instance="test1"
218 )
219 },
220 )
221 ]
222 ),
223 )
224 album_b = media_items.Album(
225 item_id="1",
226 provider="test2",
227 name="Album A",
228 provider_mappings={
229 media_items.ProviderMapping(
230 item_id="2", provider_domain="test", provider_instance="test2"
231 )
232 },
233 artists=media_items.UniqueList(
234 [
235 media_items.Artist(
236 item_id="1",
237 provider="test1",
238 name="Artist A",
239 provider_mappings={
240 media_items.ProviderMapping(
241 item_id="1", provider_domain="test", provider_instance="test1"
242 )
243 },
244 )
245 ]
246 ),
247 )
248 # test match on name match
249 assert compare.compare_album(album_a, album_b) is True
250 # test match on name mismatch
251 album_b.name = "Album B"
252 assert compare.compare_album(album_a, album_b) is False
253 # test on version mismatch
254 album_b.name = album_a.name
255 album_b.version = "Deluxe"
256 assert compare.compare_album(album_a, album_b) is False
257 album_b.version = "Remix"
258 assert compare.compare_album(album_a, album_b) is False
259 # test on version match
260 album_b.name = album_a.name
261 album_a.version = "Deluxe"
262 album_b.version = "Deluxe Edition"
263 assert compare.compare_album(album_a, album_b) is True
264 # test on exact item_id match
265 album_b.item_id = album_a.item_id
266 album_b.provider = album_a.provider
267 assert compare.compare_album(album_a, album_b) is True
268 # test on external id match
269 album_b.name = "Album B"
270 album_b.item_id = "2"
271 album_b.provider = "test2"
272 album_a.external_ids = {(ExternalID.MB_ALBUM, "123")}
273 album_b.external_ids = album_a.external_ids
274 assert compare.compare_album(album_a, album_b) is True
275 # test on external id mismatch
276 album_b.name = album_a.name
277 album_b.external_ids = {(ExternalID.MB_ALBUM, "1234")}
278 assert compare.compare_album(album_a, album_b) is False
279 album_a.external_ids = set()
280 album_b.external_ids = set()
281 # fail on year mismatch
282 album_b.external_ids = set()
283 album_a.year = 2021
284 album_b.year = 2020
285 assert compare.compare_album(album_a, album_b) is False
286 # pass on year match
287 album_b.year = 2021
288 assert compare.compare_album(album_a, album_b) is True
289 # fail on artist mismatch
290 album_a.artists = media_items.UniqueList(
291 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
292 )
293 album_b.artists = media_items.UniqueList(
294 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
295 )
296 assert compare.compare_album(album_a, album_b) is False
297 # pass on partial artist match (if first artist matches)
298 album_a.artists = media_items.UniqueList(
299 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
300 )
301 album_b.artists = media_items.UniqueList(
302 [
303 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
304 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
305 ]
306 )
307 assert compare.compare_album(album_a, album_b) is True
308 # fail on partial artist match in strict mode
309 album_b.artists = media_items.UniqueList(
310 [
311 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
312 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
313 ]
314 )
315 assert compare.compare_album(album_a, album_b) is False
316 # partial artist match is allowed in non-strict mode
317 assert compare.compare_album(album_a, album_b, False) is True
318
319
320def test_compare_album_barcode_requires_corroboration() -> None:
321 """A shared retail barcode only overrides year when album identity also agrees."""
322 artist_a = media_items.Artist(
323 item_id="1",
324 provider="test1",
325 name="Artist A",
326 provider_mappings={
327 media_items.ProviderMapping(
328 item_id="1", provider_domain="test", provider_instance="test1"
329 )
330 },
331 )
332 artist_b = media_items.Artist(
333 item_id="2",
334 provider="test2",
335 name="Artist A",
336 provider_mappings={
337 media_items.ProviderMapping(
338 item_id="2", provider_domain="test", provider_instance="test2"
339 )
340 },
341 )
342 barcode_a = (ExternalID.BARCODE, "000724354283857")
343 barcode_b = (ExternalID.BARCODE, "0724354283857")
344 album_a = media_items.Album(
345 item_id="1",
346 provider="test1",
347 name="#1",
348 year=2001,
349 artists=media_items.UniqueList([artist_a]),
350 external_ids={barcode_a},
351 provider_mappings={
352 media_items.ProviderMapping(
353 item_id="1", provider_domain="test", provider_instance="test1"
354 )
355 },
356 )
357 album_b = media_items.Album(
358 item_id="2",
359 provider="test2",
360 name="#1",
361 year=2002,
362 artists=media_items.UniqueList([artist_b]),
363 external_ids={barcode_b},
364 provider_mappings={
365 media_items.ProviderMapping(
366 item_id="2", provider_domain="test", provider_instance="test2"
367 )
368 },
369 )
370
371 assert compare.compare_album(album_a, album_b) is True
372
373 album_b.name = "Different Album"
374 assert compare.compare_album(album_a, album_b) is False
375 album_b.name = album_a.name
376 album_b.artists[0].name = "Different Artist"
377 assert compare.compare_album(album_a, album_b) is False
378
379
380def test_compare_album_evidence_barcode_never_matches_unrelated_titles() -> None:
381 """A shared canonical barcode alone must never match unrelated title/artist data."""
382 barcode = {(ExternalID.BARCODE, "0724354283857")}
383 album_a = _album(name="Album A", external_ids=barcode)
384 album_b = _album(item_id="2", provider="test2", name="Completely Different Album", year=1999)
385 album_b.external_ids = barcode
386
387 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
388 assert compare.compare_album(album_a, album_b) is False
389
390
391def test_compare_album_evidence_name_hyphen_spacing_drift_matches() -> None:
392 """Punctuation/spacing drift around a title's hyphenation does not block a match."""
393 album_a = _album(name="All Change - - EP")
394 album_b = _album(item_id="2", provider="test2", name="All Change - EP")
395
396 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
397 assert compare.compare_album(album_a, album_b) is True
398
399
400def test_compare_album_evidence_name_accent_and_apostrophe_variants_match() -> None:
401 """Diacritic and apostrophe drift in an otherwise identical title does not block a match."""
402 album_a = _album(name="Am\u00e9lie Soundtrack")
403 album_b = _album(item_id="2", provider="test2", name="Amelie Soundtrack")
404 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
405
406 album_a = _album(name="Guns N' Roses Live")
407 album_b = _album(item_id="2", provider="test2", name="Guns N Roses Live")
408 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
409
410
411def test_compare_album_evidence_name_hyphenation_vs_spacing_matches() -> None:
412 """A hyphenated title matches its spaced or joined spelling across providers."""
413 album_a = _album(name="Trans-Europe Express")
414 album_b = _album(item_id="2", provider="test2", name="Trans Europe Express")
415 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
416
417 album_a = _album(name="Hell - On")
418 album_b = _album(item_id="2", provider="test2", name="Hell-On")
419 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
420
421
422def test_compare_album_evidence_name_retail_suffix_stripped() -> None:
423 """An Apple-style ' - EP'/' - Single' retail suffix does not block a match."""
424 for suffix in (" - EP", " - Single", " \u2013 EP"):
425 album_a = _album(name=f"Album A{suffix}")
426 album_b = _album(item_id="2", provider="test2", name="Album A")
427 assert (
428 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
429 ), suffix
430
431
432def test_compare_album_evidence_punctuation_only_title_whitespace_drift_matches() -> None:
433 """Whitespace drift within a punctuation-only title does not block a match."""
434 album_a = _album(name="( )")
435 album_b = _album(item_id="2", provider="test2", name="()")
436 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
437
438 # the retail suffix is also ignored when the remaining title is punctuation-only
439 album_a = _album(name="... - EP")
440 album_b = _album(item_id="2", provider="test2", name="...")
441 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
442
443 # different punctuation-only titles are still different albums
444 album_a = _album(name="\u00f7")
445 album_b = _album(item_id="2", provider="test2", name="=")
446 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
447
448
449def test_compare_album_evidence_unrelated_punctuation_only_titles_stay_distinct() -> None:
450 """Two different titles that both normalize to nothing must not be treated as equal."""
451 album_a = _album(name="...")
452 album_b = _album(item_id="2", provider="test2", name="!!!")
453
454 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
455 assert compare.compare_album(album_a, album_b) is False
456
457
458def test_compare_album_evidence_mb_releasegroup_not_sufficient_alone() -> None:
459 """A shared MusicBrainz release-group is family evidence only, never sufficient identity."""
460 releasegroup = {(ExternalID.MB_RELEASEGROUP, "11111111-1111-1111-1111-111111111111")}
461 original = _album(name="Album A", version="Original Mix", external_ids=releasegroup)
462 remaster = _album(item_id="2", provider="test2", name="Album A", version="Live")
463 remaster.external_ids = releasegroup
464
465 # a genuine edition conflict (studio mix vs. live) must not auto-merge on releasegroup alone
466 assert compare.compare_album_evidence(original, remaster) == compare.AlbumMatchEvidence.NO_MATCH
467 assert compare.compare_album(original, remaster) is False
468
469
470def test_compare_album_evidence_ambiguous_version_without_tracks_is_insufficient() -> None:
471 """An ambiguous subset/superset edition wording without tracklists stays undecided."""
472 base_item = _album(version="2022 Remaster")
473 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
474
475 assert (
476 compare.compare_album_evidence(base_item, compare_item)
477 == compare.AlbumMatchEvidence.INSUFFICIENT
478 )
479 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
480 assert compare.compare_album(base_item, compare_item) is False
481
482
483def test_compare_album_evidence_barcode_resolves_edition_ambiguity() -> None:
484 """A shared retail barcode resolves ambiguous edition wording into a match."""
485 barcode = {(ExternalID.BARCODE, "0724354283857")}
486 base_item = _album(version="2022 Remaster", external_ids=barcode)
487 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
488 compare_item.external_ids = barcode
489
490 assert (
491 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
492 )
493
494 # the same corroboration applies when comparing against a minimized ItemMapping
495 mapping = media_items.ItemMapping(
496 item_id="3",
497 provider="test3",
498 name="Album A",
499 version="Deluxe 2022 Remaster",
500 external_ids=barcode,
501 )
502 assert (
503 compare.compare_album_evidence(base_item, mapping, strict=False)
504 == compare.AlbumMatchEvidence.MATCH
505 )
506
507 # a conflicting tracklist fingerprint still overrides the barcode corroboration
508 assert (
509 compare.compare_album_evidence(
510 base_item, compare_item, base_tracks=_tracklist(8), compare_tracks=_tracklist(14)
511 )
512 == compare.AlbumMatchEvidence.NO_MATCH
513 )
514
515
516def test_compare_album_evidence_subset_wording_with_recording_conflict_is_no_match() -> None:
517 """A subset edition wording that adds a recording-altering qualifier must not merge."""
518 base_item = _album(version="Deluxe")
519 compare_item = _album(item_id="2", provider="test2", version="Deluxe Karaoke Edition")
520
521 assert (
522 compare.compare_album_evidence(base_item, compare_item)
523 == compare.AlbumMatchEvidence.NO_MATCH
524 )
525 assert compare.compare_album(base_item, compare_item) is False
526
527
528def test_compare_album_evidence_recording_conflict_word_anywhere_is_no_match() -> None:
529 """A recording-changing qualifier stays NO_MATCH even when it's shared, not differing."""
530 # "live" is shared by both sides here (not the token that differs), but a bare
531 # "Live" tag next to a specific "Live at <venue>" tag is still not safe to merge
532 base_item = _album(version="Live")
533 compare_item = _album(item_id="2", provider="test2", version="Live at Wembley")
534
535 assert (
536 compare.compare_album_evidence(base_item, compare_item)
537 == compare.AlbumMatchEvidence.NO_MATCH
538 )
539 assert compare.compare_album(base_item, compare_item) is False
540
541
542def test_compare_album_evidence_equivalent_location_wording_matches() -> None:
543 """Reordered wording that differs only by a harmless connector word ('at') still matches."""
544 base_item = _album(version="Live at Wembley")
545 compare_item = _album(item_id="2", provider="test2", version="Wembley Live")
546
547 assert (
548 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
549 )
550 assert compare.compare_album(base_item, compare_item) is True
551
552
553def test_compare_album_evidence_blank_vs_remaster_version_is_insufficient() -> None:
554 """A blank version next to a tagged remaster is undecided, not a proven conflict."""
555 base_item = _album(version="")
556 compare_item = _album(item_id="2", provider="test2", version="Remaster")
557
558 assert (
559 compare.compare_album_evidence(base_item, compare_item)
560 == compare.AlbumMatchEvidence.INSUFFICIENT
561 )
562 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
563 assert compare.compare_album(base_item, compare_item) is False
564
565
566def test_compare_album_evidence_blank_vs_remaster_resolves_with_fingerprint() -> None:
567 """A blank-vs-remaster version gap is resolved once track fingerprints are supplied."""
568 base_item = _album(version="")
569 compare_item = _album(item_id="2", provider="test2", version="Remaster")
570 matching_tracks = _tracklist(10)
571
572 assert (
573 compare.compare_album_evidence(
574 base_item, compare_item, base_tracks=matching_tracks, compare_tracks=matching_tracks
575 )
576 == compare.AlbumMatchEvidence.MATCH
577 )
578
579 conflicting_tracks = _tracklist(10, isrc_prefix="USRC28718")
580 assert (
581 compare.compare_album_evidence(
582 base_item,
583 compare_item,
584 base_tracks=matching_tracks,
585 compare_tracks=conflicting_tracks,
586 )
587 == compare.AlbumMatchEvidence.NO_MATCH
588 )
589
590
591@pytest.mark.parametrize(
592 "conflict_version",
593 ["Live", "Remix", "Karaoke Edition", "Instrumental", "Acoustic Version", "Demo", "Cover"],
594)
595def test_compare_album_evidence_blank_vs_recording_conflict_is_no_match(
596 conflict_version: str,
597) -> None:
598 """
599 A blank version next to a recording-changing qualifier is a proven conflict.
600
601 Unlike a blank version next to plain packaging wording (remaster, deluxe, ...), a
602 missing version tag can never explain away a recording-altering qualifier on the
603 other side, so this must not be left for a tracklist to resolve.
604 """
605 base_item = _album(version="")
606 compare_item = _album(item_id="2", provider="test2", version=conflict_version)
607
608 assert (
609 compare.compare_album_evidence(base_item, compare_item)
610 == compare.AlbumMatchEvidence.NO_MATCH
611 )
612 assert compare.compare_album(base_item, compare_item) is False
613
614
615@pytest.mark.parametrize("packaging_version", ["Remaster", "Deluxe Edition", "Anniversary Edition"])
616def test_compare_album_evidence_blank_vs_packaging_edition_is_insufficient(
617 packaging_version: str,
618) -> None:
619 """A blank version next to plain packaging wording stays undecided, not a proven conflict."""
620 base_item = _album(version="")
621 compare_item = _album(item_id="2", provider="test2", version=packaging_version)
622
623 assert (
624 compare.compare_album_evidence(base_item, compare_item)
625 == compare.AlbumMatchEvidence.INSUFFICIENT
626 )
627 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
628 assert compare.compare_album(base_item, compare_item) is False
629
630
631def test_compare_album_evidence_resolves_with_matching_fingerprint() -> None:
632 """Ambiguous edition wording resolves to MATCH once track fingerprints agree."""
633 base_item = _album(version="2022 Remaster")
634 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
635 base_tracks = _tracklist(14)
636 compare_tracks = _tracklist(14)
637
638 assert (
639 compare.compare_album_evidence(
640 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
641 )
642 == compare.AlbumMatchEvidence.MATCH
643 )
644
645
646def test_compare_album_evidence_resolves_with_conflicting_fingerprint() -> None:
647 """Ambiguous edition wording resolves to NO_MATCH once track fingerprints disagree."""
648 base_item = _album(version="2022 Remaster")
649 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
650 base_tracks = _tracklist(14, isrc_prefix="USRC17607")
651 compare_tracks = _tracklist(14, isrc_prefix="USRC28718")
652
653 assert (
654 compare.compare_album_evidence(
655 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
656 )
657 == compare.AlbumMatchEvidence.NO_MATCH
658 )
659
660
661def test_compare_album_evidence_ordinary_and_deluxe_track_counts_stay_distinct() -> None:
662 """An ordinary 8-track album and a 14-track edition must not be merged via fingerprints."""
663 base_item = _album(version="2022 Remaster")
664 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
665 base_tracks = _tracklist(8)
666 compare_tracks = _tracklist(14)
667
668 assert (
669 compare.compare_album_evidence(
670 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
671 )
672 == compare.AlbumMatchEvidence.NO_MATCH
673 )
674
675
676def test_compare_album_evidence_fingerprint_overrides_matching_metadata() -> None:
677 """A conflicting tracklist overrides an otherwise nominally-matching album."""
678 base_item = _album(version="", year=2020)
679 compare_item = _album(item_id="2", provider="test2", version="", year=2020)
680 base_tracks = _tracklist(8)
681 compare_tracks = _tracklist(14)
682
683 assert (
684 compare.compare_album_evidence(
685 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
686 )
687 == compare.AlbumMatchEvidence.NO_MATCH
688 )
689
690
691def test_compare_album_track_fingerprint_matching_isrc_and_duration() -> None:
692 """Equal ISRC, title and duration at every position is a confident match."""
693 base_tracks = _tracklist(3)
694 compare_tracks = _tracklist(3)
695
696 assert (
697 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
698 == compare.AlbumMatchEvidence.MATCH
699 )
700
701
702def test_compare_album_track_fingerprint_shared_isrc_without_duration_is_insufficient() -> None:
703 """A shared ISRC alone cannot confirm a match if neither side has a duration."""
704 base_tracks = [_track("1", track_number=1, isrc="USRC17607839", duration=0)]
705 compare_tracks = [_track("2", track_number=1, isrc="USRC17607839", duration=0)]
706
707 assert (
708 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
709 == compare.AlbumMatchEvidence.INSUFFICIENT
710 )
711
712
713def test_compare_album_track_fingerprint_conflicting_isrc() -> None:
714 """Conflicting ISRCs at the same position indicate a different recording/remaster."""
715 base_tracks = [_track("1", track_number=1, isrc="USRC17607839")]
716 compare_tracks = [_track("2", track_number=1, isrc="USRC28718001")]
717
718 assert (
719 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
720 == compare.AlbumMatchEvidence.NO_MATCH
721 )
722
723
724def test_compare_album_track_fingerprint_invalid_isrc_falls_back_to_title_duration() -> None:
725 """A structurally invalid ISRC is ignored, not treated as identity evidence."""
726 base_tracks = [_track("1", track_number=1, name="Track One", duration=200, isrc="NOTANISRC")]
727 matching_compare_tracks = [
728 _track("2", track_number=1, name="Track One", duration=200, isrc="ALSOINVALID")
729 ]
730
731 # both sides tag an (invalid) ISRC, but neither is structurally valid, so the
732 # comparison falls back to title/duration and still finds a match
733 assert (
734 compare.compare_album_track_fingerprint(base_tracks, matching_compare_tracks)
735 == compare.AlbumMatchEvidence.MATCH
736 )
737
738 # a genuine title conflict is still caught once the invalid ISRCs are ignored
739 conflicting_compare_tracks = [
740 _track("2", track_number=1, name="Different Track", duration=200, isrc="ALSOINVALID")
741 ]
742 assert (
743 compare.compare_album_track_fingerprint(base_tracks, conflicting_compare_tracks)
744 == compare.AlbumMatchEvidence.NO_MATCH
745 )
746
747
748def test_compare_album_track_fingerprint_title_duration_fallback() -> None:
749 """Without ISRCs, matching normalized title/version and a tight duration match."""
750 base_tracks = [_track("1", track_number=1, name="Track One", duration=200)]
751 compare_tracks = [_track("2", track_number=1, name="Track One", duration=201)]
752
753 assert (
754 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
755 == compare.AlbumMatchEvidence.MATCH
756 )
757 # a genuine duration conflict without ISRCs cannot be treated as the same recording
758 compare_tracks = [_track("2", track_number=1, name="Track One", duration=260)]
759 assert (
760 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
761 == compare.AlbumMatchEvidence.NO_MATCH
762 )
763
764
765def test_compare_album_track_fingerprint_sparse_tracks_are_insufficient() -> None:
766 """Tracks with no ISRC, title, or duration cannot support a confident decision."""
767 base_tracks = [_track("1", track_number=1, name="", duration=0)]
768 compare_tracks = [_track("2", track_number=1, name="", duration=0)]
769
770 assert (
771 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
772 == compare.AlbumMatchEvidence.INSUFFICIENT
773 )
774 # no tracklists supplied at all is equally undecided
775 assert (
776 compare.compare_album_track_fingerprint(None, None)
777 == compare.AlbumMatchEvidence.INSUFFICIENT
778 )
779 assert (
780 compare.compare_album_track_fingerprint([], []) == compare.AlbumMatchEvidence.INSUFFICIENT
781 )
782
783
784def test_compare_album_track_fingerprint_unknown_disc_layout_vs_multi_disc_is_insufficient() -> (
785 None
786):
787 """An unknown (no disc numbers reported at all) layout can't be shape-compared."""
788 # a provider that never reports disc numbers: assumed single disc by omission
789 base_tracks = [_track(str(n), disc_number=0, track_number=n) for n in range(1, 15)]
790 # the other side is a genuine 2-disc release (8 + 6 tracks)
791 compare_tracks = [_track(f"d1-{n}", disc_number=1, track_number=n) for n in range(1, 9)] + [
792 _track(f"d2-{n}", disc_number=2, track_number=n) for n in range(1, 7)
793 ]
794
795 # assuming disc 1 for the unknown side would falsely look like a shape conflict
796 # (or, worse, a false match): neither is safe, so this must stay undecided
797 assert (
798 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
799 == compare.AlbumMatchEvidence.INSUFFICIENT
800 )
801
802
803def test_album_tracks_have_positions() -> None:
804 """A trustworthy disc/track layout is recognized, an ambiguous one is not."""
805 assert compare.album_tracks_have_positions(_tracklist(10)) is True
806 assert compare.album_tracks_have_positions(None) is False
807 assert compare.album_tracks_have_positions([]) is False
808 # a missing track number makes the layout untrustworthy
809 assert compare.album_tracks_have_positions([_track("1", track_number=0)]) is False
810 # a missing disc number is treated as unknown, not silently assumed to be disc 1
811 assert compare.album_tracks_have_positions([_track("1", disc_number=0)]) is False
812 # a duplicate position cannot be trusted either
813 duplicate = [_track("1", track_number=1), _track("2", track_number=1)]
814 assert compare.album_tracks_have_positions(duplicate) is False
815
816
817def test_compare_external_ids_checks_all_unique_values() -> None:
818 """One mismatching unique identifier does not hide another matching value."""
819 base_ids = {
820 (ExternalID.MB_ALBUM, "11111111-1111-1111-1111-111111111111"),
821 (ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222"),
822 }
823 compare_ids = {(ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222")}
824
825 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.MB_ALBUM) is True
826
827
828def test_compare_external_ids_truncated_barcode_matches_full_value() -> None:
829 """A truncated 13-digit GTIN-14 (Qobuz) matches another provider's full barcode."""
830 base_ids = {(ExternalID.BARCODE, "0060252758365")}
831 compare_ids = {(ExternalID.BARCODE, "00602527583655")}
832
833 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.BARCODE) is True
834
835
836def test_compare_track() -> None: # noqa: PLR0915
837 """Test track comparison."""
838 track_a = media_items.Track(
839 item_id="1",
840 provider="test1",
841 name="Track A",
842 provider_mappings={
843 media_items.ProviderMapping(
844 item_id="1", provider_domain="test", provider_instance="test1"
845 )
846 },
847 artists=media_items.UniqueList(
848 [
849 media_items.Artist(
850 item_id="1",
851 provider="test1",
852 name="Artist A",
853 provider_mappings={
854 media_items.ProviderMapping(
855 item_id="1", provider_domain="test", provider_instance="test1"
856 )
857 },
858 )
859 ]
860 ),
861 )
862 track_b = media_items.Track(
863 item_id="1",
864 provider="test2",
865 name="Track A",
866 provider_mappings={
867 media_items.ProviderMapping(
868 item_id="2", provider_domain="test", provider_instance="test2"
869 )
870 },
871 artists=media_items.UniqueList(
872 [
873 media_items.Artist(
874 item_id="1",
875 provider="test1",
876 name="Artist A",
877 provider_mappings={
878 media_items.ProviderMapping(
879 item_id="1", provider_domain="test", provider_instance="test1"
880 )
881 },
882 )
883 ]
884 ),
885 )
886 # test match on name match
887 assert compare.compare_track(track_a, track_b) is True
888 # test match on name mismatch
889 track_b.name = "Track B"
890 assert compare.compare_track(track_a, track_b) is False
891 # test on version mismatch
892 track_b.name = track_a.name
893 track_b.version = "Deluxe"
894 assert compare.compare_track(track_a, track_b) is False
895 track_b.version = "Remix"
896 assert compare.compare_track(track_a, track_b) is False
897 # test on version mismatch
898 track_b.name = track_a.name
899 track_a.version = ""
900 track_b.version = "Remaster"
901 assert compare.compare_track(track_a, track_b) is False
902 track_b.version = "Remix"
903 assert compare.compare_track(track_a, track_b) is False
904 # test on version match
905 track_b.name = track_a.name
906 track_a.version = "Deluxe"
907 track_b.version = "Deluxe Edition"
908 assert compare.compare_track(track_a, track_b) is True
909 # test on exact item_id match
910 track_b.item_id = track_a.item_id
911 track_b.provider = track_a.provider
912 assert compare.compare_track(track_a, track_b) is True
913 # test on external id match
914 track_b.name = "Track B"
915 track_b.item_id = "2"
916 track_b.provider = "test2"
917 track_a.external_ids = {(ExternalID.MB_RECORDING, "123")}
918 track_b.external_ids = track_a.external_ids
919 assert compare.compare_track(track_a, track_b) is True
920 # test on external id mismatch
921 track_b.name = track_a.name
922 track_b.external_ids = {(ExternalID.MB_RECORDING, "1234")}
923 assert compare.compare_track(track_a, track_b) is False
924 track_a.external_ids = set()
925 track_b.external_ids = set()
926 # fail on artist mismatch
927 track_a.artists = media_items.UniqueList(
928 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
929 )
930 track_b.artists = media_items.UniqueList(
931 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
932 )
933 assert compare.compare_track(track_a, track_b) is False
934 # pass on partial artist match (if first artist matches)
935 track_a.artists = media_items.UniqueList(
936 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
937 )
938 track_b.artists = media_items.UniqueList(
939 [
940 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
941 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
942 ]
943 )
944 assert compare.compare_track(track_a, track_b) is True
945 # fail on partial artist match in strict mode
946 track_b.artists = media_items.UniqueList(
947 [
948 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
949 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
950 ]
951 )
952 assert compare.compare_track(track_a, track_b) is False
953 # partial artist match is allowed in non-strict mode
954 assert compare.compare_track(track_a, track_b, False) is True
955 track_b.artists = track_a.artists
956 # fail on album mismatch
957 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
958 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
959 assert compare.compare_track(track_a, track_b) is False
960 # pass on exact album(track) match (regardless duration)
961 track_b.album = track_a.album
962 track_a.disc_number = 1
963 track_a.track_number = 1
964 track_b.disc_number = track_a.disc_number
965 track_b.track_number = track_a.track_number
966 track_a.duration = 300
967 track_b.duration = 310
968 assert compare.compare_track(track_a, track_b) is True
969 # pass on album(track) mismatch
970 track_b.album = track_a.album
971 track_a.disc_number = 1
972 track_a.track_number = 1
973 track_b.disc_number = track_a.disc_number
974 track_b.track_number = 2
975 track_b.duration = track_a.duration
976 assert compare.compare_track(track_a, track_b) is False
977 # test special case - ISRC match but MusicBrainz ID mismatch
978 # this can happen for some classical music albums
979 track_a.external_ids = {
980 (ExternalID.ISRC, "123"),
981 (ExternalID.MB_RECORDING, "abc"),
982 }
983 track_b.external_ids = {
984 (ExternalID.ISRC, "123"),
985 (ExternalID.MB_RECORDING, "abcd"),
986 }
987 assert compare.compare_track(track_a, track_b) is False
988 # test multi-disc: same album, same external IDs, different disc numbers should NOT match
989 track_a.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
990 track_b.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
991 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
992 track_b.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
993 track_a.disc_number = 1
994 track_b.disc_number = 2
995 track_a.track_number = 3
996 track_b.track_number = 3
997 assert compare.compare_track(track_a, track_b) is False
998 # same disc number should still match via external ID
999 track_b.disc_number = 1
1000 assert compare.compare_track(track_a, track_b) is True
1001 # different disc but different albums should still match via external ID
1002 track_b.disc_number = 2
1003 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
1004 assert compare.compare_track(track_a, track_b) is True
1005
1006
1007def test_compare_track_missing_disc_number_assumes_disc_one() -> None:
1008 """A track without a disc number tag still makes the exact albumtrack match on disc 1."""
1009
1010 def _albumtrack(item_id: str, provider: str, disc_number: int) -> media_items.Track:
1011 return media_items.Track(
1012 item_id=item_id,
1013 provider=provider,
1014 name="Track A",
1015 duration=300,
1016 disc_number=disc_number,
1017 track_number=5,
1018 artists=media_items.UniqueList(
1019 [media_items.ItemMapping(item_id="1", provider=provider, name="Artist A")]
1020 ),
1021 album=media_items.ItemMapping(item_id="1", provider=provider, name="Album A"),
1022 provider_mappings={
1023 media_items.ProviderMapping(
1024 item_id=item_id, provider_domain=provider, provider_instance=provider
1025 )
1026 },
1027 )
1028
1029 untagged = _albumtrack("1", "test1", disc_number=0)
1030 disc_one = _albumtrack("2", "test2", disc_number=1)
1031 # durations differ beyond every fallback tolerance: only the albumtrack path can match
1032 disc_one.duration = 320
1033 assert compare.compare_track(untagged, disc_one) is True
1034
1035 # an unknown disc number only ever assumes disc 1, never a higher disc
1036 disc_two = _albumtrack("3", "test2", disc_number=2)
1037 disc_two.duration = 320
1038 assert compare.compare_track(untagged, disc_two) is False
1039
1040
1041def test_compare_strings_case_insensitive_fuzzy() -> None:
1042 """Test that non-strict fuzzy matching is fully case-insensitive."""
1043 # These differ slightly ("Feat." vs "FT.") so create_safe_string won't match,
1044 # falling through to SequenceMatcher which must compare both strings lowered.
1045 assert compare.compare_strings("Track Feat. John", "TRACK FT. JOHN", strict=False) is True
1046
1047
1048def test_loose_compare_strings_containment_both_directions() -> None:
1049 """Partial containment matches regardless of which side has the extra wording."""
1050 assert compare.loose_compare_strings("Some Track", "Some Track (Acoustic)") is True
1051 assert compare.loose_compare_strings("Some Track (Acoustic)", "Some Track") is True
1052
1053
1054def test_compare_radio() -> None:
1055 """Test the radio compare helper."""
1056
1057 def _radio(
1058 item_id: str, provider: str, name: str, *, is_dynamic: bool = False
1059 ) -> media_items.Radio:
1060 return media_items.Radio(
1061 item_id=item_id,
1062 provider=provider,
1063 name=name,
1064 is_dynamic=is_dynamic,
1065 provider_mappings={
1066 media_items.ProviderMapping(
1067 item_id=item_id, provider_domain=provider, provider_instance=provider
1068 )
1069 },
1070 )
1071
1072 live_a = _radio("a", "tunein", "Chill Vibes")
1073 live_b = _radio("b", "radiobrowser", "Chill Vibes")
1074 # a live station is matched across providers on its name
1075 assert compare.compare_radio(live_a, live_b) is True
1076
1077 station = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1078 # a dynamic station only exists on its own provider, so the name is not enough
1079 assert compare.compare_radio(station, live_a) is False
1080 assert compare.compare_radio(live_a, station) is False
1081 # ... but it is still recognised as itself
1082 same = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1083 assert compare.compare_radio(station, same) is True
1084