/
/
/
1"""Tests for mediaitem compare helper functions."""
2
3import sqlite3
4
5import pytest
6from music_assistant_models import media_items
7from music_assistant_models.enums import ExternalID
8
9from music_assistant.helpers import compare
10
11
12def _album(
13 item_id: str = "1",
14 provider: str = "test1",
15 name: str = "Album A",
16 version: str = "",
17 year: int | None = None,
18 external_ids: set[tuple[ExternalID, str]] | None = None,
19) -> media_items.Album:
20 """Build a minimal Album for evidence comparisons."""
21 return media_items.Album(
22 item_id=item_id,
23 provider=provider,
24 name=name,
25 version=version,
26 year=year,
27 external_ids=external_ids or set(),
28 artists=media_items.UniqueList(
29 [
30 media_items.Artist(
31 item_id="artist",
32 provider=provider,
33 name="Artist A",
34 provider_mappings={
35 media_items.ProviderMapping(
36 item_id="artist", provider_domain="test", provider_instance=provider
37 )
38 },
39 )
40 ]
41 ),
42 provider_mappings={
43 media_items.ProviderMapping(
44 item_id=item_id, provider_domain="test", provider_instance=provider
45 )
46 },
47 )
48
49
50def _track(
51 item_id: str,
52 disc_number: int = 1,
53 track_number: int = 1,
54 name: str = "Track",
55 version: str = "",
56 duration: int = 200,
57 isrc: str | None = None,
58) -> media_items.Track:
59 """Build a minimal Track for album-track fingerprint comparisons."""
60 return media_items.Track(
61 item_id=item_id,
62 provider="test1",
63 name=name,
64 version=version,
65 duration=duration,
66 disc_number=disc_number,
67 track_number=track_number,
68 external_ids={(ExternalID.ISRC, isrc)} if isrc else set(),
69 provider_mappings={
70 media_items.ProviderMapping(
71 item_id=item_id, provider_domain="test", provider_instance="test1"
72 )
73 },
74 )
75
76
77def _tracklist(
78 count: int, *, isrc_prefix: str = "USRC17607", duration: int = 200
79) -> list[media_items.Track]:
80 """Build an ordered list of distinct tracks sharing a common ISRC/duration scheme."""
81 return [
82 _track(
83 item_id=str(number),
84 track_number=number,
85 name=f"Track {number}",
86 duration=duration,
87 isrc=f"{isrc_prefix}{number:03d}",
88 )
89 for number in range(1, count + 1)
90 ]
91
92
93def test_compare_version() -> None:
94 """Test the version compare helper."""
95 assert compare.compare_version("Remaster", "remaster") is True
96 assert compare.compare_version("Remastered", "remaster") is True
97 assert compare.compare_version("Remaster", "") is False
98 assert compare.compare_version("Remaster", "Remix") is False
99 assert compare.compare_version("", "Deluxe") is False
100 assert compare.compare_version("", "Live") is False
101 assert compare.compare_version("Live", "live") is True
102 assert compare.compare_version("Live", "live version") is True
103 assert compare.compare_version("Live version", "live") is True
104 assert compare.compare_version("Deluxe Edition", "Deluxe") is True
105 assert compare.compare_version("Deluxe Karaoke Edition", "Deluxe") is False
106 assert compare.compare_version("Deluxe Karaoke Edition", "Karaoke") is False
107 assert compare.compare_version("Deluxe Edition", "Edition Deluxe") is True
108 assert compare.compare_version("", "Karaoke Version") is False
109 assert compare.compare_version("Karaoke", "Karaoke Version") is True
110 assert compare.compare_version("Remaster", "Remaster Edition Deluxe") is False
111 assert compare.compare_version("Remastered Version", "Deluxe Version") is False
112 assert compare.compare_version("2011 Remaster", "Remastered 2011") is True
113 assert compare.compare_version("", "Album Version") is True
114 assert compare.compare_version("Deluxe 2022 Remaster", "2022 Remaster") is False
115
116
117def test_compare_version_deduplicates_repeated_tokens() -> None:
118 """Repeated wording inside one version string does not block a match."""
119 assert (
120 compare.compare_version("Deluxe [2022 Remaster] 2022 Remaster", "Deluxe 2022 Remaster")
121 is True
122 )
123
124
125def test_compare_version_ignores_hi_res_wording() -> None:
126 """Quality-only wording (hi-res) is ignored wherever it appears in a version."""
127 assert compare.compare_version("Remastered", "Remastered Hi-Res Version") is True
128 assert compare.compare_version("Hi-Res Version", "") is True
129 assert compare.compare_version("Hi-Res", "Remastered") is False
130
131
132def test_compare_artist() -> None:
133 """Test artist comparison."""
134 artist_a = media_items.Artist(
135 item_id="1",
136 provider="test1",
137 name="Artist A",
138 provider_mappings={
139 media_items.ProviderMapping(
140 item_id="1", provider_domain="test", provider_instance="test1"
141 )
142 },
143 )
144 artist_b = media_items.Artist(
145 item_id="1",
146 provider="test2",
147 name="Artist A",
148 provider_mappings={
149 media_items.ProviderMapping(
150 item_id="2", provider_domain="test", provider_instance="test2"
151 )
152 },
153 )
154 # test match on name match
155 assert compare.compare_artist(artist_a, artist_b) is True
156 # test match on name mismatch
157 artist_b.name = "Artist B"
158 assert compare.compare_artist(artist_a, artist_b) is False
159 # test on exact item_id match
160 artist_b.item_id = artist_a.item_id
161 artist_b.provider = artist_a.provider
162 assert compare.compare_artist(artist_a, artist_b) is True
163 # test on external id match
164 artist_b.name = "Artist B"
165 artist_b.item_id = "2"
166 artist_b.provider = "test2"
167 artist_a.external_ids = {(ExternalID.MB_ARTIST, "123")}
168 artist_b.external_ids = artist_a.external_ids
169 assert compare.compare_artist(artist_a, artist_b) is True
170 # test on external id mismatch
171 artist_b.name = artist_a.name
172 artist_b.external_ids = {(ExternalID.MB_ARTIST, "1234")}
173 assert compare.compare_artist(artist_a, artist_b) is False
174 # test on external id mismatch while name matches
175 artist_a = media_items.Artist(
176 item_id="1",
177 provider="test1",
178 name="Artist A",
179 external_ids={(ExternalID.MB_ARTIST, "123")},
180 provider_mappings={
181 media_items.ProviderMapping(
182 item_id="1", provider_domain="test", provider_instance="test1"
183 )
184 },
185 )
186 artist_b = media_items.Artist(
187 item_id="1",
188 provider="test2",
189 name="Artist A",
190 external_ids={(ExternalID.MB_ARTIST, "abc")},
191 provider_mappings={
192 media_items.ProviderMapping(
193 item_id="2", provider_domain="test", provider_instance="test2"
194 )
195 },
196 )
197 assert compare.compare_artist(artist_a, artist_b) is False
198
199
200def test_compare_artist_name_normalization() -> None:
201 """An accent difference in an artist name does not block a strict match."""
202 artist_a = media_items.Artist(
203 item_id="1",
204 provider="test1",
205 name="Bj\u00f6rk",
206 provider_mappings={
207 media_items.ProviderMapping(
208 item_id="1", provider_domain="test", provider_instance="test1"
209 )
210 },
211 )
212 artist_b = media_items.Artist(
213 item_id="2",
214 provider="test2",
215 name="Bjork",
216 provider_mappings={
217 media_items.ProviderMapping(
218 item_id="2", provider_domain="test", provider_instance="test2"
219 )
220 },
221 )
222
223 assert compare.compare_artist(artist_a, artist_b) is True
224
225
226def test_compare_album() -> None:
227 """Test album comparison."""
228 album_a = media_items.Album(
229 item_id="1",
230 provider="test1",
231 name="Album A",
232 provider_mappings={
233 media_items.ProviderMapping(
234 item_id="1", provider_domain="test", provider_instance="test1"
235 )
236 },
237 artists=media_items.UniqueList(
238 [
239 media_items.Artist(
240 item_id="1",
241 provider="test1",
242 name="Artist A",
243 provider_mappings={
244 media_items.ProviderMapping(
245 item_id="1", provider_domain="test", provider_instance="test1"
246 )
247 },
248 )
249 ]
250 ),
251 )
252 album_b = media_items.Album(
253 item_id="1",
254 provider="test2",
255 name="Album A",
256 provider_mappings={
257 media_items.ProviderMapping(
258 item_id="2", provider_domain="test", provider_instance="test2"
259 )
260 },
261 artists=media_items.UniqueList(
262 [
263 media_items.Artist(
264 item_id="1",
265 provider="test1",
266 name="Artist A",
267 provider_mappings={
268 media_items.ProviderMapping(
269 item_id="1", provider_domain="test", provider_instance="test1"
270 )
271 },
272 )
273 ]
274 ),
275 )
276 # test match on name match
277 assert compare.compare_album(album_a, album_b) is True
278 # test match on name mismatch
279 album_b.name = "Album B"
280 assert compare.compare_album(album_a, album_b) is False
281 # test on version mismatch
282 album_b.name = album_a.name
283 album_b.version = "Deluxe"
284 assert compare.compare_album(album_a, album_b) is False
285 album_b.version = "Remix"
286 assert compare.compare_album(album_a, album_b) is False
287 # test on version match
288 album_b.name = album_a.name
289 album_a.version = "Deluxe"
290 album_b.version = "Deluxe Edition"
291 assert compare.compare_album(album_a, album_b) is True
292 # test on exact item_id match
293 album_b.item_id = album_a.item_id
294 album_b.provider = album_a.provider
295 assert compare.compare_album(album_a, album_b) is True
296 # test on external id match
297 album_b.name = "Album B"
298 album_b.item_id = "2"
299 album_b.provider = "test2"
300 album_a.external_ids = {(ExternalID.MB_ALBUM, "123")}
301 album_b.external_ids = album_a.external_ids
302 assert compare.compare_album(album_a, album_b) is True
303 # test on external id mismatch
304 album_b.name = album_a.name
305 album_b.external_ids = {(ExternalID.MB_ALBUM, "1234")}
306 assert compare.compare_album(album_a, album_b) is False
307 album_a.external_ids = set()
308 album_b.external_ids = set()
309 # fail on year mismatch
310 album_b.external_ids = set()
311 album_a.year = 2021
312 album_b.year = 2020
313 assert compare.compare_album(album_a, album_b) is False
314 # pass on year match
315 album_b.year = 2021
316 assert compare.compare_album(album_a, album_b) is True
317 # fail on artist mismatch
318 album_a.artists = media_items.UniqueList(
319 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
320 )
321 album_b.artists = media_items.UniqueList(
322 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
323 )
324 assert compare.compare_album(album_a, album_b) is False
325 # pass on partial artist match (if first artist matches)
326 album_a.artists = media_items.UniqueList(
327 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
328 )
329 album_b.artists = media_items.UniqueList(
330 [
331 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
332 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
333 ]
334 )
335 assert compare.compare_album(album_a, album_b) is True
336 # fail on partial artist match in strict mode
337 album_b.artists = media_items.UniqueList(
338 [
339 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
340 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
341 ]
342 )
343 assert compare.compare_album(album_a, album_b) is False
344 # partial artist match is allowed in non-strict mode
345 assert compare.compare_album(album_a, album_b, False) is True
346
347
348def test_compare_album_barcode_requires_corroboration() -> None:
349 """A shared retail barcode only overrides year when album identity also agrees."""
350 artist_a = media_items.Artist(
351 item_id="1",
352 provider="test1",
353 name="Artist A",
354 provider_mappings={
355 media_items.ProviderMapping(
356 item_id="1", provider_domain="test", provider_instance="test1"
357 )
358 },
359 )
360 artist_b = media_items.Artist(
361 item_id="2",
362 provider="test2",
363 name="Artist A",
364 provider_mappings={
365 media_items.ProviderMapping(
366 item_id="2", provider_domain="test", provider_instance="test2"
367 )
368 },
369 )
370 barcode_a = (ExternalID.BARCODE, "000724354283857")
371 barcode_b = (ExternalID.BARCODE, "0724354283857")
372 album_a = media_items.Album(
373 item_id="1",
374 provider="test1",
375 name="#1",
376 year=2001,
377 artists=media_items.UniqueList([artist_a]),
378 external_ids={barcode_a},
379 provider_mappings={
380 media_items.ProviderMapping(
381 item_id="1", provider_domain="test", provider_instance="test1"
382 )
383 },
384 )
385 album_b = media_items.Album(
386 item_id="2",
387 provider="test2",
388 name="#1",
389 year=2002,
390 artists=media_items.UniqueList([artist_b]),
391 external_ids={barcode_b},
392 provider_mappings={
393 media_items.ProviderMapping(
394 item_id="2", provider_domain="test", provider_instance="test2"
395 )
396 },
397 )
398
399 assert compare.compare_album(album_a, album_b) is True
400
401 album_b.name = "Different Album"
402 assert compare.compare_album(album_a, album_b) is False
403 album_b.name = album_a.name
404 album_b.artists[0].name = "Different Artist"
405 assert compare.compare_album(album_a, album_b) is False
406
407
408def test_compare_album_evidence_barcode_never_matches_unrelated_titles() -> None:
409 """A shared canonical barcode alone must never match unrelated title/artist data."""
410 barcode = {(ExternalID.BARCODE, "0724354283857")}
411 album_a = _album(name="Album A", external_ids=barcode)
412 album_b = _album(item_id="2", provider="test2", name="Completely Different Album", year=1999)
413 album_b.external_ids = barcode
414
415 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
416 assert compare.compare_album(album_a, album_b) is False
417
418
419def test_compare_album_evidence_name_hyphen_spacing_drift_matches() -> None:
420 """Punctuation/spacing drift around a title's hyphenation does not block a match."""
421 album_a = _album(name="All Change - - EP")
422 album_b = _album(item_id="2", provider="test2", name="All Change - EP")
423
424 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
425 assert compare.compare_album(album_a, album_b) is True
426
427
428def test_compare_album_evidence_name_accent_and_apostrophe_variants_match() -> None:
429 """Diacritic and apostrophe drift in an otherwise identical title does not block a match."""
430 album_a = _album(name="Am\u00e9lie Soundtrack")
431 album_b = _album(item_id="2", provider="test2", name="Amelie Soundtrack")
432 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
433
434 album_a = _album(name="Guns N' Roses Live")
435 album_b = _album(item_id="2", provider="test2", name="Guns N Roses Live")
436 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
437
438
439def test_compare_album_evidence_name_hyphenation_vs_spacing_matches() -> None:
440 """A hyphenated title matches its spaced or joined spelling across providers."""
441 album_a = _album(name="Trans-Europe Express")
442 album_b = _album(item_id="2", provider="test2", name="Trans Europe Express")
443 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
444
445 album_a = _album(name="Hell - On")
446 album_b = _album(item_id="2", provider="test2", name="Hell-On")
447 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
448
449
450def test_compare_album_evidence_name_retail_suffix_stripped() -> None:
451 """A retail suffix, however a provider sets it off, does not block a match."""
452 for suffix in (
453 " - EP",
454 " -EP",
455 " \u2013 EP",
456 " (EP)",
457 " [EP]",
458 " - Single",
459 " (Single)",
460 ):
461 album_a = _album(name=f"Album A{suffix}")
462 album_b = _album(item_id="2", provider="test2", name="Album A")
463 assert (
464 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
465 ), suffix
466
467
468def test_compare_album_evidence_retail_suffix_spelled_differently_matches() -> None:
469 """Two providers setting off the same retail suffix differently name the same album."""
470 for spelling in (" -EP", " \u2013 EP", " (EP)", " [EP]"):
471 album_a = _album(name="Album A - EP")
472 album_b = _album(item_id="2", provider="test2", name=f"Album A{spelling}")
473 assert (
474 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
475 ), spelling
476
477
478def test_album_retail_suffix_sql_match_needs_the_suffix_set_off() -> None:
479 """The pre-filter condition sees every separator style, but not an ordinary title."""
480 connection = sqlite3.connect(":memory:")
481
482 def relates(name: str, suffix_key: str) -> bool:
483 condition = compare.album_retail_suffix_sql_match("?", suffix_key)
484 return bool(connection.execute(f"SELECT {condition}", (name,)).fetchone()[0])
485
486 for name in (
487 "Album A - EP",
488 "Album A -EP",
489 "Album A \u2013 EP",
490 "Album A \u2014EP",
491 "Album A (EP)",
492 "Album A [EP]",
493 "Album A - ep",
494 # a bare trailing word is related here on purpose and refused by the comparison
495 "The SL2 EP",
496 ):
497 assert relates(name, "ep"), name
498 for name in ("Step", "Sleep", "EP"):
499 assert not relates(name, "ep"), name
500
501 assert relates("Think of You - Single", "single")
502 assert relates("Brazen 'Weep' (CD1) (single)", "single")
503 for name in ("Singles", "Every Single Day"):
504 assert not relates(name, "single"), name
505
506
507def test_compare_album_evidence_bare_suffix_word_stays_part_of_the_title() -> None:
508 """A trailing suffix word that is not set off belongs to the title itself."""
509 for name in ("The SL2 EP", "Saturday Night Single"):
510 album_a = _album(name=name)
511 album_b = _album(item_id="2", provider="test2", name=name.rsplit(" ", 1)[0])
512 assert (
513 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
514 ), name
515
516 # two providers spelling that same title identically still match
517 album_a = _album(name="The SL2 EP")
518 album_b = _album(item_id="2", provider="test2", name="The SL2 EP")
519 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
520
521
522def test_compare_album_evidence_bordering_symbol_marks_a_different_release() -> None:
523 """A bonus edition marked by a symbol at either end of the title is a release of its own."""
524 for name in ("MOTOMAMI +", "MOTOMAMI+", "+ MOTOMAMI", "+MOTOMAMI"):
525 album_a = _album(name="MOTOMAMI", year=2022)
526 album_b = _album(item_id="2", provider="test2", name=name, year=2022)
527
528 assert (
529 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
530 ), name
531 assert compare.compare_album(album_a, album_b) is False, name
532
533 # how the symbol is spaced is still formatting the two providers may differ on
534 album_a = _album(name="MOTOMAMI +", year=2022)
535 album_b = _album(item_id="2", provider="test2", name="MOTOMAMI+", year=2022)
536 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
537
538
539def test_compare_album_evidence_symbol_standing_in_for_letters_is_still_drift() -> None:
540 """A symbol used to spell a letter is folded away, so both spellings still match."""
541 for stylized, plain in (("bbno$", "bbno"), ("\u0060Round Midnight", "'Round Midnight")):
542 album_a = _album(name=stylized)
543 album_b = _album(item_id="2", provider="test2", name=plain)
544 assert (
545 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
546 ), stylized
547
548 # a mathematical symbol anyascii spells out is counted once, not twice
549 album_a = _album(name="Partial \u2202")
550 album_b = _album(item_id="2", provider="test2", name="Partial d")
551 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
552
553
554def test_compare_album_evidence_symbol_only_titles_identify_their_own_album() -> None:
555 """A symbol-titled album (Ed Sheeran's '+', '=', '÷') matches only its own spelling."""
556 for name in ("+", "=", "÷", "\u00d7"):
557 assert (
558 compare.compare_album_evidence(
559 _album(name=name), _album(item_id="2", provider="test2", name=f"{name} ")
560 )
561 == compare.AlbumMatchEvidence.MATCH
562 ), name
563 # a symbol-only title is decided on its complete raw spelling, punctuation included
564 for base_name, compare_name in (
565 ("+", "="),
566 ("+", "÷"),
567 ("=", "÷"),
568 ("\u00d7", "÷"),
569 ("+", "+!"),
570 ):
571 assert (
572 compare.compare_album_evidence(
573 _album(name=base_name), _album(item_id="2", provider="test2", name=compare_name)
574 )
575 == compare.AlbumMatchEvidence.NO_MATCH
576 ), (base_name, compare_name)
577
578
579def test_compare_album_evidence_standalone_separator_between_words_is_drift() -> None:
580 """A symbol standing between words is a separator, so it never blocks a match."""
581 album_a = _album(name="HIStory - PAST, PRESENT AND FUTURE - BOOK I", year=1995)
582 album_b = _album(
583 item_id="2",
584 provider="test2",
585 name="HIStory: Past, Present and Future, Book I",
586 year=1995,
587 )
588
589 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
590
591
592def test_compare_album_evidence_ep_and_single_of_the_same_name_stay_distinct() -> None:
593 """Two titles naming a different format are separate releases, whatever base they share."""
594 album_a = _album(name="Stargazing - EP", year=2023)
595 album_b = _album(item_id="2", provider="test2", name="Stargazing - Single", year=2023)
596
597 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
598
599 # either spelling still matches the plain title, which names no format at all
600 for name in ("Stargazing - EP", "Stargazing - Single"):
601 album_a = _album(name=name, year=2023)
602 album_b = _album(item_id="2", provider="test2", name="Stargazing", year=2023)
603 assert (
604 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
605 ), name
606
607
608def test_compare_album_evidence_punctuation_only_title_whitespace_drift_matches() -> None:
609 """Whitespace drift within a punctuation-only title does not block a match."""
610 album_a = _album(name="( )")
611 album_b = _album(item_id="2", provider="test2", name="()")
612 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
613
614 # the retail suffix is also ignored when the remaining title is punctuation-only
615 album_a = _album(name="... - EP")
616 album_b = _album(item_id="2", provider="test2", name="...")
617 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
618
619 # different punctuation-only titles are still different albums
620 album_a = _album(name="\u00f7")
621 album_b = _album(item_id="2", provider="test2", name="=")
622 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
623
624
625def test_compare_album_evidence_unrelated_punctuation_only_titles_stay_distinct() -> None:
626 """Two different titles that both normalize to nothing must not be treated as equal."""
627 album_a = _album(name="...")
628 album_b = _album(item_id="2", provider="test2", name="!!!")
629
630 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
631 assert compare.compare_album(album_a, album_b) is False
632
633
634def test_compare_album_evidence_mb_releasegroup_not_sufficient_alone() -> None:
635 """A shared MusicBrainz release-group is family evidence only, never sufficient identity."""
636 releasegroup = {(ExternalID.MB_RELEASEGROUP, "11111111-1111-1111-1111-111111111111")}
637 original = _album(name="Album A", version="Original Mix", external_ids=releasegroup)
638 remaster = _album(item_id="2", provider="test2", name="Album A", version="Live")
639 remaster.external_ids = releasegroup
640
641 # a genuine edition conflict (studio mix vs. live) must not auto-merge on releasegroup alone
642 assert compare.compare_album_evidence(original, remaster) == compare.AlbumMatchEvidence.NO_MATCH
643 assert compare.compare_album(original, remaster) is False
644
645
646def test_compare_album_evidence_ambiguous_version_without_tracks_is_insufficient() -> None:
647 """An ambiguous subset/superset edition wording without tracklists stays undecided."""
648 base_item = _album(version="2022 Remaster")
649 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
650
651 assert (
652 compare.compare_album_evidence(base_item, compare_item)
653 == compare.AlbumMatchEvidence.INSUFFICIENT
654 )
655 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
656 assert compare.compare_album(base_item, compare_item) is False
657
658
659def test_compare_album_evidence_barcode_resolves_edition_ambiguity() -> None:
660 """A shared retail barcode resolves ambiguous edition wording into a match."""
661 barcode = {(ExternalID.BARCODE, "0724354283857")}
662 base_item = _album(version="2022 Remaster", external_ids=barcode)
663 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
664 compare_item.external_ids = barcode
665
666 assert (
667 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
668 )
669
670 # the same corroboration applies when comparing against a minimized ItemMapping
671 mapping = media_items.ItemMapping(
672 item_id="3",
673 provider="test3",
674 name="Album A",
675 version="Deluxe 2022 Remaster",
676 external_ids=barcode,
677 )
678 assert (
679 compare.compare_album_evidence(base_item, mapping, strict=False)
680 == compare.AlbumMatchEvidence.MATCH
681 )
682
683 # a conflicting tracklist fingerprint still overrides the barcode corroboration
684 assert (
685 compare.compare_album_evidence(
686 base_item, compare_item, base_tracks=_tracklist(8), compare_tracks=_tracklist(14)
687 )
688 == compare.AlbumMatchEvidence.NO_MATCH
689 )
690
691
692def test_compare_album_evidence_subset_wording_with_recording_conflict_is_no_match() -> None:
693 """A subset edition wording that adds a recording-altering qualifier must not merge."""
694 base_item = _album(version="Deluxe")
695 compare_item = _album(item_id="2", provider="test2", version="Deluxe Karaoke Edition")
696
697 assert (
698 compare.compare_album_evidence(base_item, compare_item)
699 == compare.AlbumMatchEvidence.NO_MATCH
700 )
701 assert compare.compare_album(base_item, compare_item) is False
702
703
704def test_compare_album_evidence_recording_conflict_word_anywhere_is_no_match() -> None:
705 """A recording-changing qualifier stays NO_MATCH even when it's shared, not differing."""
706 # "live" is shared by both sides here (not the token that differs), but a bare
707 # "Live" tag next to a specific "Live at <venue>" tag is still not safe to merge
708 base_item = _album(version="Live")
709 compare_item = _album(item_id="2", provider="test2", version="Live at Wembley")
710
711 assert (
712 compare.compare_album_evidence(base_item, compare_item)
713 == compare.AlbumMatchEvidence.NO_MATCH
714 )
715 assert compare.compare_album(base_item, compare_item) is False
716
717
718def test_compare_album_evidence_equivalent_location_wording_matches() -> None:
719 """Reordered wording that differs only by a harmless connector word ('at') still matches."""
720 base_item = _album(version="Live at Wembley")
721 compare_item = _album(item_id="2", provider="test2", version="Wembley Live")
722
723 assert (
724 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
725 )
726 assert compare.compare_album(base_item, compare_item) is True
727
728
729def test_compare_album_evidence_blank_vs_remaster_version_is_insufficient() -> None:
730 """A blank version next to a tagged remaster is undecided, not a proven conflict."""
731 base_item = _album(version="")
732 compare_item = _album(item_id="2", provider="test2", version="Remaster")
733
734 assert (
735 compare.compare_album_evidence(base_item, compare_item)
736 == compare.AlbumMatchEvidence.INSUFFICIENT
737 )
738 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
739 assert compare.compare_album(base_item, compare_item) is False
740
741
742def test_compare_album_evidence_blank_vs_remaster_resolves_with_fingerprint() -> None:
743 """A blank-vs-remaster version gap is resolved once track fingerprints are supplied."""
744 base_item = _album(version="")
745 compare_item = _album(item_id="2", provider="test2", version="Remaster")
746 matching_tracks = _tracklist(10)
747
748 assert (
749 compare.compare_album_evidence(
750 base_item, compare_item, base_tracks=matching_tracks, compare_tracks=matching_tracks
751 )
752 == compare.AlbumMatchEvidence.MATCH
753 )
754
755 conflicting_tracks = _tracklist(10, isrc_prefix="USRC28718")
756 assert (
757 compare.compare_album_evidence(
758 base_item,
759 compare_item,
760 base_tracks=matching_tracks,
761 compare_tracks=conflicting_tracks,
762 )
763 == compare.AlbumMatchEvidence.NO_MATCH
764 )
765
766
767@pytest.mark.parametrize(
768 "conflict_version",
769 ["Live", "Remix", "Karaoke Edition", "Instrumental", "Acoustic Version", "Demo", "Cover"],
770)
771def test_compare_album_evidence_blank_vs_recording_conflict_is_no_match(
772 conflict_version: str,
773) -> None:
774 """
775 A blank version next to a recording-changing qualifier is a proven conflict.
776
777 Unlike a blank version next to plain packaging wording (remaster, deluxe, ...), a
778 missing version tag can never explain away a recording-altering qualifier on the
779 other side, so this must not be left for a tracklist to resolve.
780 """
781 base_item = _album(version="")
782 compare_item = _album(item_id="2", provider="test2", version=conflict_version)
783
784 assert (
785 compare.compare_album_evidence(base_item, compare_item)
786 == compare.AlbumMatchEvidence.NO_MATCH
787 )
788 assert compare.compare_album(base_item, compare_item) is False
789
790
791@pytest.mark.parametrize("packaging_version", ["Remaster", "Deluxe Edition", "Anniversary Edition"])
792def test_compare_album_evidence_blank_vs_packaging_edition_is_insufficient(
793 packaging_version: str,
794) -> None:
795 """A blank version next to plain packaging wording stays undecided, not a proven conflict."""
796 base_item = _album(version="")
797 compare_item = _album(item_id="2", provider="test2", version=packaging_version)
798
799 assert (
800 compare.compare_album_evidence(base_item, compare_item)
801 == compare.AlbumMatchEvidence.INSUFFICIENT
802 )
803 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
804 assert compare.compare_album(base_item, compare_item) is False
805
806
807def test_compare_album_evidence_resolves_with_matching_fingerprint() -> None:
808 """Ambiguous edition wording resolves to MATCH once track fingerprints agree."""
809 base_item = _album(version="2022 Remaster")
810 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
811 base_tracks = _tracklist(14)
812 compare_tracks = _tracklist(14)
813
814 assert (
815 compare.compare_album_evidence(
816 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
817 )
818 == compare.AlbumMatchEvidence.MATCH
819 )
820
821
822def test_compare_album_evidence_resolves_with_conflicting_fingerprint() -> None:
823 """Ambiguous edition wording resolves to NO_MATCH once track fingerprints disagree."""
824 base_item = _album(version="2022 Remaster")
825 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
826 base_tracks = _tracklist(14, isrc_prefix="USRC17607")
827 compare_tracks = _tracklist(14, isrc_prefix="USRC28718")
828
829 assert (
830 compare.compare_album_evidence(
831 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
832 )
833 == compare.AlbumMatchEvidence.NO_MATCH
834 )
835
836
837def test_compare_album_evidence_ordinary_and_deluxe_track_counts_stay_distinct() -> None:
838 """An ordinary 8-track album and a 14-track edition must not be merged via fingerprints."""
839 base_item = _album(version="2022 Remaster")
840 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
841 base_tracks = _tracklist(8)
842 compare_tracks = _tracklist(14)
843
844 assert (
845 compare.compare_album_evidence(
846 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
847 )
848 == compare.AlbumMatchEvidence.NO_MATCH
849 )
850
851
852def test_compare_album_evidence_fingerprint_overrides_matching_metadata() -> None:
853 """A conflicting tracklist overrides an otherwise nominally-matching album."""
854 base_item = _album(version="", year=2020)
855 compare_item = _album(item_id="2", provider="test2", version="", year=2020)
856 base_tracks = _tracklist(8)
857 compare_tracks = _tracklist(14)
858
859 assert (
860 compare.compare_album_evidence(
861 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
862 )
863 == compare.AlbumMatchEvidence.NO_MATCH
864 )
865
866
867def test_compare_album_track_fingerprint_matching_isrc_and_duration() -> None:
868 """Equal ISRC, title and duration at every position is a confident match."""
869 base_tracks = _tracklist(3)
870 compare_tracks = _tracklist(3)
871
872 assert (
873 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
874 == compare.AlbumMatchEvidence.MATCH
875 )
876
877
878def test_compare_album_track_fingerprint_shared_isrc_without_duration_is_insufficient() -> None:
879 """A shared ISRC alone cannot confirm a match if neither side has a duration."""
880 base_tracks = [_track("1", track_number=1, isrc="USRC17607839", duration=0)]
881 compare_tracks = [_track("2", track_number=1, isrc="USRC17607839", duration=0)]
882
883 assert (
884 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
885 == compare.AlbumMatchEvidence.INSUFFICIENT
886 )
887
888
889def test_compare_album_track_fingerprint_conflicting_isrc() -> None:
890 """Conflicting ISRCs at the same position indicate a different recording/remaster."""
891 base_tracks = [_track("1", track_number=1, isrc="USRC17607839")]
892 compare_tracks = [_track("2", track_number=1, isrc="USRC28718001")]
893
894 assert (
895 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
896 == compare.AlbumMatchEvidence.NO_MATCH
897 )
898
899
900def test_compare_album_track_fingerprint_invalid_isrc_falls_back_to_title_duration() -> None:
901 """A structurally invalid ISRC is ignored, not treated as identity evidence."""
902 base_tracks = [_track("1", track_number=1, name="Track One", duration=200, isrc="NOTANISRC")]
903 matching_compare_tracks = [
904 _track("2", track_number=1, name="Track One", duration=200, isrc="ALSOINVALID")
905 ]
906
907 # both sides tag an (invalid) ISRC, but neither is structurally valid, so the
908 # comparison falls back to title/duration and still finds a match
909 assert (
910 compare.compare_album_track_fingerprint(base_tracks, matching_compare_tracks)
911 == compare.AlbumMatchEvidence.MATCH
912 )
913
914 # a genuine title conflict is still caught once the invalid ISRCs are ignored
915 conflicting_compare_tracks = [
916 _track("2", track_number=1, name="Different Track", duration=200, isrc="ALSOINVALID")
917 ]
918 assert (
919 compare.compare_album_track_fingerprint(base_tracks, conflicting_compare_tracks)
920 == compare.AlbumMatchEvidence.NO_MATCH
921 )
922
923
924def test_compare_album_track_fingerprint_title_duration_fallback() -> None:
925 """Without ISRCs, matching normalized title/version and a tight duration match."""
926 base_tracks = [_track("1", track_number=1, name="Track One", duration=200)]
927 compare_tracks = [_track("2", track_number=1, name="Track One", duration=201)]
928
929 assert (
930 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
931 == compare.AlbumMatchEvidence.MATCH
932 )
933 # a genuine duration conflict without ISRCs cannot be treated as the same recording
934 compare_tracks = [_track("2", track_number=1, name="Track One", duration=260)]
935 assert (
936 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
937 == compare.AlbumMatchEvidence.NO_MATCH
938 )
939
940
941def test_compare_album_track_fingerprint_sparse_tracks_are_insufficient() -> None:
942 """Tracks with no ISRC, title, or duration cannot support a confident decision."""
943 base_tracks = [_track("1", track_number=1, name="", duration=0)]
944 compare_tracks = [_track("2", track_number=1, name="", duration=0)]
945
946 assert (
947 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
948 == compare.AlbumMatchEvidence.INSUFFICIENT
949 )
950 # no tracklists supplied at all is equally undecided
951 assert (
952 compare.compare_album_track_fingerprint(None, None)
953 == compare.AlbumMatchEvidence.INSUFFICIENT
954 )
955 assert (
956 compare.compare_album_track_fingerprint([], []) == compare.AlbumMatchEvidence.INSUFFICIENT
957 )
958
959
960def test_compare_album_track_fingerprint_unknown_disc_layout_vs_multi_disc_is_insufficient() -> (
961 None
962):
963 """An unknown (no disc numbers reported at all) layout can't be shape-compared."""
964 # a provider that never reports disc numbers: assumed single disc by omission
965 base_tracks = [_track(str(n), disc_number=0, track_number=n) for n in range(1, 15)]
966 # the other side is a genuine 2-disc release (8 + 6 tracks)
967 compare_tracks = [_track(f"d1-{n}", disc_number=1, track_number=n) for n in range(1, 9)] + [
968 _track(f"d2-{n}", disc_number=2, track_number=n) for n in range(1, 7)
969 ]
970
971 # assuming disc 1 for the unknown side would falsely look like a shape conflict
972 # (or, worse, a false match): neither is safe, so this must stay undecided
973 assert (
974 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
975 == compare.AlbumMatchEvidence.INSUFFICIENT
976 )
977
978
979def test_album_tracks_have_positions() -> None:
980 """A trustworthy disc/track layout is recognized, an ambiguous one is not."""
981 assert compare.album_tracks_have_positions(_tracklist(10)) is True
982 assert compare.album_tracks_have_positions(None) is False
983 assert compare.album_tracks_have_positions([]) is False
984 # a missing track number makes the layout untrustworthy
985 assert compare.album_tracks_have_positions([_track("1", track_number=0)]) is False
986 # a missing disc number is treated as unknown, not silently assumed to be disc 1
987 assert compare.album_tracks_have_positions([_track("1", disc_number=0)]) is False
988 # a duplicate position cannot be trusted either
989 duplicate = [_track("1", track_number=1), _track("2", track_number=1)]
990 assert compare.album_tracks_have_positions(duplicate) is False
991
992
993def test_compare_external_ids_checks_all_unique_values() -> None:
994 """One mismatching unique identifier does not hide another matching value."""
995 base_ids = {
996 (ExternalID.MB_ALBUM, "11111111-1111-1111-1111-111111111111"),
997 (ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222"),
998 }
999 compare_ids = {(ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222")}
1000
1001 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.MB_ALBUM) is True
1002
1003
1004def test_compare_external_ids_truncated_barcode_matches_full_value() -> None:
1005 """A truncated 13-digit GTIN-14 (Qobuz) matches another provider's full barcode."""
1006 base_ids = {(ExternalID.BARCODE, "0060252758365")}
1007 compare_ids = {(ExternalID.BARCODE, "00602527583655")}
1008
1009 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.BARCODE) is True
1010
1011
1012def test_compare_track() -> None: # noqa: PLR0915
1013 """Test track comparison."""
1014 track_a = media_items.Track(
1015 item_id="1",
1016 provider="test1",
1017 name="Track A",
1018 provider_mappings={
1019 media_items.ProviderMapping(
1020 item_id="1", provider_domain="test", provider_instance="test1"
1021 )
1022 },
1023 artists=media_items.UniqueList(
1024 [
1025 media_items.Artist(
1026 item_id="1",
1027 provider="test1",
1028 name="Artist A",
1029 provider_mappings={
1030 media_items.ProviderMapping(
1031 item_id="1", provider_domain="test", provider_instance="test1"
1032 )
1033 },
1034 )
1035 ]
1036 ),
1037 )
1038 track_b = media_items.Track(
1039 item_id="1",
1040 provider="test2",
1041 name="Track A",
1042 provider_mappings={
1043 media_items.ProviderMapping(
1044 item_id="2", provider_domain="test", provider_instance="test2"
1045 )
1046 },
1047 artists=media_items.UniqueList(
1048 [
1049 media_items.Artist(
1050 item_id="1",
1051 provider="test1",
1052 name="Artist A",
1053 provider_mappings={
1054 media_items.ProviderMapping(
1055 item_id="1", provider_domain="test", provider_instance="test1"
1056 )
1057 },
1058 )
1059 ]
1060 ),
1061 )
1062 # test match on name match
1063 assert compare.compare_track(track_a, track_b) is True
1064 # test match on name mismatch
1065 track_b.name = "Track B"
1066 assert compare.compare_track(track_a, track_b) is False
1067 # test on version mismatch
1068 track_b.name = track_a.name
1069 track_b.version = "Deluxe"
1070 assert compare.compare_track(track_a, track_b) is False
1071 track_b.version = "Remix"
1072 assert compare.compare_track(track_a, track_b) is False
1073 # test on version mismatch
1074 track_b.name = track_a.name
1075 track_a.version = ""
1076 track_b.version = "Remaster"
1077 assert compare.compare_track(track_a, track_b) is False
1078 track_b.version = "Remix"
1079 assert compare.compare_track(track_a, track_b) is False
1080 # test on version match
1081 track_b.name = track_a.name
1082 track_a.version = "Deluxe"
1083 track_b.version = "Deluxe Edition"
1084 assert compare.compare_track(track_a, track_b) is True
1085 # test on exact item_id match
1086 track_b.item_id = track_a.item_id
1087 track_b.provider = track_a.provider
1088 assert compare.compare_track(track_a, track_b) is True
1089 # test on external id match
1090 track_b.name = "Track B"
1091 track_b.item_id = "2"
1092 track_b.provider = "test2"
1093 track_a.external_ids = {(ExternalID.MB_RECORDING, "123")}
1094 track_b.external_ids = track_a.external_ids
1095 assert compare.compare_track(track_a, track_b) is True
1096 # test on external id mismatch
1097 track_b.name = track_a.name
1098 track_b.external_ids = {(ExternalID.MB_RECORDING, "1234")}
1099 assert compare.compare_track(track_a, track_b) is False
1100 track_a.external_ids = set()
1101 track_b.external_ids = set()
1102 # fail on artist mismatch
1103 track_a.artists = media_items.UniqueList(
1104 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
1105 )
1106 track_b.artists = media_items.UniqueList(
1107 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
1108 )
1109 assert compare.compare_track(track_a, track_b) is False
1110 # pass on partial artist match (if first artist matches)
1111 track_a.artists = media_items.UniqueList(
1112 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
1113 )
1114 track_b.artists = media_items.UniqueList(
1115 [
1116 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
1117 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
1118 ]
1119 )
1120 assert compare.compare_track(track_a, track_b) is True
1121 # fail on partial artist match in strict mode
1122 track_b.artists = media_items.UniqueList(
1123 [
1124 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
1125 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
1126 ]
1127 )
1128 assert compare.compare_track(track_a, track_b) is False
1129 # partial artist match is allowed in non-strict mode
1130 assert compare.compare_track(track_a, track_b, False) is True
1131 track_b.artists = track_a.artists
1132 # fail on album mismatch
1133 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1134 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
1135 assert compare.compare_track(track_a, track_b) is False
1136 # pass on exact album(track) match (regardless duration)
1137 track_b.album = track_a.album
1138 track_a.disc_number = 1
1139 track_a.track_number = 1
1140 track_b.disc_number = track_a.disc_number
1141 track_b.track_number = track_a.track_number
1142 track_a.duration = 300
1143 track_b.duration = 310
1144 assert compare.compare_track(track_a, track_b) is True
1145 # pass on album(track) mismatch
1146 track_b.album = track_a.album
1147 track_a.disc_number = 1
1148 track_a.track_number = 1
1149 track_b.disc_number = track_a.disc_number
1150 track_b.track_number = 2
1151 track_b.duration = track_a.duration
1152 assert compare.compare_track(track_a, track_b) is False
1153 # test special case - ISRC match but MusicBrainz ID mismatch
1154 # this can happen for some classical music albums
1155 track_a.external_ids = {
1156 (ExternalID.ISRC, "123"),
1157 (ExternalID.MB_RECORDING, "abc"),
1158 }
1159 track_b.external_ids = {
1160 (ExternalID.ISRC, "123"),
1161 (ExternalID.MB_RECORDING, "abcd"),
1162 }
1163 assert compare.compare_track(track_a, track_b) is False
1164 # test multi-disc: same album, same external IDs, different disc numbers should NOT match
1165 track_a.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
1166 track_b.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
1167 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1168 track_b.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1169 track_a.disc_number = 1
1170 track_b.disc_number = 2
1171 track_a.track_number = 3
1172 track_b.track_number = 3
1173 assert compare.compare_track(track_a, track_b) is False
1174 # same disc number should still match via external ID
1175 track_b.disc_number = 1
1176 assert compare.compare_track(track_a, track_b) is True
1177 # different disc but different albums should still match via external ID
1178 track_b.disc_number = 2
1179 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
1180 assert compare.compare_track(track_a, track_b) is True
1181
1182
1183def test_compare_track_missing_disc_number_assumes_disc_one() -> None:
1184 """A track without a disc number tag still makes the exact albumtrack match on disc 1."""
1185
1186 def _albumtrack(item_id: str, provider: str, disc_number: int) -> media_items.Track:
1187 return media_items.Track(
1188 item_id=item_id,
1189 provider=provider,
1190 name="Track A",
1191 duration=300,
1192 disc_number=disc_number,
1193 track_number=5,
1194 artists=media_items.UniqueList(
1195 [media_items.ItemMapping(item_id="1", provider=provider, name="Artist A")]
1196 ),
1197 album=media_items.ItemMapping(item_id="1", provider=provider, name="Album A"),
1198 provider_mappings={
1199 media_items.ProviderMapping(
1200 item_id=item_id, provider_domain=provider, provider_instance=provider
1201 )
1202 },
1203 )
1204
1205 untagged = _albumtrack("1", "test1", disc_number=0)
1206 disc_one = _albumtrack("2", "test2", disc_number=1)
1207 # durations differ beyond every fallback tolerance: only the albumtrack path can match
1208 disc_one.duration = 320
1209 assert compare.compare_track(untagged, disc_one) is True
1210
1211 # an unknown disc number only ever assumes disc 1, never a higher disc
1212 disc_two = _albumtrack("3", "test2", disc_number=2)
1213 disc_two.duration = 320
1214 assert compare.compare_track(untagged, disc_two) is False
1215
1216
1217def test_compare_strings_accent_drift_matches() -> None:
1218 """Diacritics do not distinguish two otherwise identical names."""
1219 assert compare.compare_strings("Sigur R\u00f3s", "Sigur Ros") is True
1220 assert compare.compare_strings("C\u00e9line Dion", "Celine Dion") is True
1221 assert compare.compare_strings("Bj\u00f6rk", "Bjork") is True
1222
1223
1224def test_compare_strings_punctuation_and_spacing_drift_matches() -> None:
1225 """Apostrophe, punctuation and spacing drift does not distinguish two names."""
1226 assert compare.compare_strings("Jane's Addiction", "Jane\u2019s Addiction") is True
1227 assert compare.compare_strings("A.C. Newman", "AC Newman") is True
1228 assert compare.compare_strings("All-4-One", "All4One") is True
1229
1230
1231def test_compare_strings_symbol_only_names_stay_distinct() -> None:
1232 """Names that normalize to nothing are compared raw, so only real duplicates match."""
1233 assert compare.compare_strings("!!!", "...") is False
1234 # spacing drift within such a name is still the same name
1235 assert compare.compare_strings("( )", "()") is True
1236 # a name that normalizes to nothing is never the same as one that doesn't
1237 assert compare.compare_strings("!!!", "Chk Chk Chk") is False
1238
1239
1240def test_compare_strings_normalization_does_not_leak_into_fuzzy() -> None:
1241 """The non-strict path keeps its own (more lenient) verdicts."""
1242 assert compare.compare_strings("!!!", "...", strict=False) is True
1243
1244
1245def test_compare_strings_case_insensitive_fuzzy() -> None:
1246 """Test that non-strict fuzzy matching is fully case-insensitive."""
1247 # These differ slightly ("Feat." vs "FT.") so create_safe_string won't match,
1248 # falling through to SequenceMatcher which must compare both strings lowered.
1249 assert compare.compare_strings("Track Feat. John", "TRACK FT. JOHN", strict=False) is True
1250
1251
1252def test_loose_compare_strings_containment_both_directions() -> None:
1253 """Partial containment matches regardless of which side has the extra wording."""
1254 assert compare.loose_compare_strings("Some Track", "Some Track (Acoustic)") is True
1255 assert compare.loose_compare_strings("Some Track (Acoustic)", "Some Track") is True
1256
1257
1258def test_compare_radio() -> None:
1259 """Test the radio compare helper."""
1260
1261 def _radio(
1262 item_id: str, provider: str, name: str, *, is_dynamic: bool = False
1263 ) -> media_items.Radio:
1264 return media_items.Radio(
1265 item_id=item_id,
1266 provider=provider,
1267 name=name,
1268 is_dynamic=is_dynamic,
1269 provider_mappings={
1270 media_items.ProviderMapping(
1271 item_id=item_id, provider_domain=provider, provider_instance=provider
1272 )
1273 },
1274 )
1275
1276 live_a = _radio("a", "tunein", "Chill Vibes")
1277 live_b = _radio("b", "radiobrowser", "Chill Vibes")
1278 # a live station is matched across providers on its name
1279 assert compare.compare_radio(live_a, live_b) is True
1280
1281 station = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1282 # a dynamic station only exists on its own provider, so the name is not enough
1283 assert compare.compare_radio(station, live_a) is False
1284 assert compare.compare_radio(live_a, station) is False
1285 # ... but it is still recognised as itself
1286 same = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1287 assert compare.compare_radio(station, same) is True
1288