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