/
/
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_bordering_symbol_marks_a_different_release() -> None:
459 """A bonus edition marked by a symbol at either end of the title is a release of its own."""
460 for name in ("MOTOMAMI +", "MOTOMAMI+", "+ MOTOMAMI", "+MOTOMAMI"):
461 album_a = _album(name="MOTOMAMI", year=2022)
462 album_b = _album(item_id="2", provider="test2", name=name, year=2022)
463
464 assert (
465 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
466 ), name
467 assert compare.compare_album(album_a, album_b) is False, name
468
469 # how the symbol is spaced is still formatting the two providers may differ on
470 album_a = _album(name="MOTOMAMI +", year=2022)
471 album_b = _album(item_id="2", provider="test2", name="MOTOMAMI+", year=2022)
472 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
473
474
475def test_compare_album_evidence_symbol_standing_in_for_letters_is_still_drift() -> None:
476 """A symbol used to spell a letter is folded away, so both spellings still match."""
477 for stylized, plain in (("bbno$", "bbno"), ("\u0060Round Midnight", "'Round Midnight")):
478 album_a = _album(name=stylized)
479 album_b = _album(item_id="2", provider="test2", name=plain)
480 assert (
481 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
482 ), stylized
483
484 # a mathematical symbol anyascii spells out is counted once, not twice
485 album_a = _album(name="Partial \u2202")
486 album_b = _album(item_id="2", provider="test2", name="Partial d")
487 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
488
489
490def test_compare_album_evidence_symbol_only_titles_identify_their_own_album() -> None:
491 """A symbol-titled album (Ed Sheeran's '+', '=', '÷') matches only its own spelling."""
492 for name in ("+", "=", "÷", "\u00d7"):
493 assert (
494 compare.compare_album_evidence(
495 _album(name=name), _album(item_id="2", provider="test2", name=f"{name} ")
496 )
497 == compare.AlbumMatchEvidence.MATCH
498 ), name
499 # a symbol-only title is decided on its complete raw spelling, punctuation included
500 for base_name, compare_name in (
501 ("+", "="),
502 ("+", "÷"),
503 ("=", "÷"),
504 ("\u00d7", "÷"),
505 ("+", "+!"),
506 ):
507 assert (
508 compare.compare_album_evidence(
509 _album(name=base_name), _album(item_id="2", provider="test2", name=compare_name)
510 )
511 == compare.AlbumMatchEvidence.NO_MATCH
512 ), (base_name, compare_name)
513
514
515def test_compare_album_evidence_standalone_separator_between_words_is_drift() -> None:
516 """A symbol standing between words is a separator, so it never blocks a match."""
517 album_a = _album(name="HIStory - PAST, PRESENT AND FUTURE - BOOK I", year=1995)
518 album_b = _album(
519 item_id="2",
520 provider="test2",
521 name="HIStory: Past, Present and Future, Book I",
522 year=1995,
523 )
524
525 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
526
527
528def test_compare_album_evidence_ep_and_single_of_the_same_name_stay_distinct() -> None:
529 """Two titles naming a different format are separate releases, whatever base they share."""
530 album_a = _album(name="Stargazing - EP", year=2023)
531 album_b = _album(item_id="2", provider="test2", name="Stargazing - Single", year=2023)
532
533 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
534
535 # either spelling still matches the plain title, which names no format at all
536 for name in ("Stargazing - EP", "Stargazing - Single"):
537 album_a = _album(name=name, year=2023)
538 album_b = _album(item_id="2", provider="test2", name="Stargazing", year=2023)
539 assert (
540 compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
541 ), name
542
543
544def test_compare_album_evidence_punctuation_only_title_whitespace_drift_matches() -> None:
545 """Whitespace drift within a punctuation-only title does not block a match."""
546 album_a = _album(name="( )")
547 album_b = _album(item_id="2", provider="test2", name="()")
548 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
549
550 # the retail suffix is also ignored when the remaining title is punctuation-only
551 album_a = _album(name="... - EP")
552 album_b = _album(item_id="2", provider="test2", name="...")
553 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.MATCH
554
555 # different punctuation-only titles are still different albums
556 album_a = _album(name="\u00f7")
557 album_b = _album(item_id="2", provider="test2", name="=")
558 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
559
560
561def test_compare_album_evidence_unrelated_punctuation_only_titles_stay_distinct() -> None:
562 """Two different titles that both normalize to nothing must not be treated as equal."""
563 album_a = _album(name="...")
564 album_b = _album(item_id="2", provider="test2", name="!!!")
565
566 assert compare.compare_album_evidence(album_a, album_b) == compare.AlbumMatchEvidence.NO_MATCH
567 assert compare.compare_album(album_a, album_b) is False
568
569
570def test_compare_album_evidence_mb_releasegroup_not_sufficient_alone() -> None:
571 """A shared MusicBrainz release-group is family evidence only, never sufficient identity."""
572 releasegroup = {(ExternalID.MB_RELEASEGROUP, "11111111-1111-1111-1111-111111111111")}
573 original = _album(name="Album A", version="Original Mix", external_ids=releasegroup)
574 remaster = _album(item_id="2", provider="test2", name="Album A", version="Live")
575 remaster.external_ids = releasegroup
576
577 # a genuine edition conflict (studio mix vs. live) must not auto-merge on releasegroup alone
578 assert compare.compare_album_evidence(original, remaster) == compare.AlbumMatchEvidence.NO_MATCH
579 assert compare.compare_album(original, remaster) is False
580
581
582def test_compare_album_evidence_ambiguous_version_without_tracks_is_insufficient() -> None:
583 """An ambiguous subset/superset edition wording without tracklists stays undecided."""
584 base_item = _album(version="2022 Remaster")
585 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
586
587 assert (
588 compare.compare_album_evidence(base_item, compare_item)
589 == compare.AlbumMatchEvidence.INSUFFICIENT
590 )
591 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
592 assert compare.compare_album(base_item, compare_item) is False
593
594
595def test_compare_album_evidence_barcode_resolves_edition_ambiguity() -> None:
596 """A shared retail barcode resolves ambiguous edition wording into a match."""
597 barcode = {(ExternalID.BARCODE, "0724354283857")}
598 base_item = _album(version="2022 Remaster", external_ids=barcode)
599 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
600 compare_item.external_ids = barcode
601
602 assert (
603 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
604 )
605
606 # the same corroboration applies when comparing against a minimized ItemMapping
607 mapping = media_items.ItemMapping(
608 item_id="3",
609 provider="test3",
610 name="Album A",
611 version="Deluxe 2022 Remaster",
612 external_ids=barcode,
613 )
614 assert (
615 compare.compare_album_evidence(base_item, mapping, strict=False)
616 == compare.AlbumMatchEvidence.MATCH
617 )
618
619 # a conflicting tracklist fingerprint still overrides the barcode corroboration
620 assert (
621 compare.compare_album_evidence(
622 base_item, compare_item, base_tracks=_tracklist(8), compare_tracks=_tracklist(14)
623 )
624 == compare.AlbumMatchEvidence.NO_MATCH
625 )
626
627
628def test_compare_album_evidence_subset_wording_with_recording_conflict_is_no_match() -> None:
629 """A subset edition wording that adds a recording-altering qualifier must not merge."""
630 base_item = _album(version="Deluxe")
631 compare_item = _album(item_id="2", provider="test2", version="Deluxe Karaoke Edition")
632
633 assert (
634 compare.compare_album_evidence(base_item, compare_item)
635 == compare.AlbumMatchEvidence.NO_MATCH
636 )
637 assert compare.compare_album(base_item, compare_item) is False
638
639
640def test_compare_album_evidence_recording_conflict_word_anywhere_is_no_match() -> None:
641 """A recording-changing qualifier stays NO_MATCH even when it's shared, not differing."""
642 # "live" is shared by both sides here (not the token that differs), but a bare
643 # "Live" tag next to a specific "Live at <venue>" tag is still not safe to merge
644 base_item = _album(version="Live")
645 compare_item = _album(item_id="2", provider="test2", version="Live at Wembley")
646
647 assert (
648 compare.compare_album_evidence(base_item, compare_item)
649 == compare.AlbumMatchEvidence.NO_MATCH
650 )
651 assert compare.compare_album(base_item, compare_item) is False
652
653
654def test_compare_album_evidence_equivalent_location_wording_matches() -> None:
655 """Reordered wording that differs only by a harmless connector word ('at') still matches."""
656 base_item = _album(version="Live at Wembley")
657 compare_item = _album(item_id="2", provider="test2", version="Wembley Live")
658
659 assert (
660 compare.compare_album_evidence(base_item, compare_item) == compare.AlbumMatchEvidence.MATCH
661 )
662 assert compare.compare_album(base_item, compare_item) is True
663
664
665def test_compare_album_evidence_blank_vs_remaster_version_is_insufficient() -> None:
666 """A blank version next to a tagged remaster is undecided, not a proven conflict."""
667 base_item = _album(version="")
668 compare_item = _album(item_id="2", provider="test2", version="Remaster")
669
670 assert (
671 compare.compare_album_evidence(base_item, compare_item)
672 == compare.AlbumMatchEvidence.INSUFFICIENT
673 )
674 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
675 assert compare.compare_album(base_item, compare_item) is False
676
677
678def test_compare_album_evidence_blank_vs_remaster_resolves_with_fingerprint() -> None:
679 """A blank-vs-remaster version gap is resolved once track fingerprints are supplied."""
680 base_item = _album(version="")
681 compare_item = _album(item_id="2", provider="test2", version="Remaster")
682 matching_tracks = _tracklist(10)
683
684 assert (
685 compare.compare_album_evidence(
686 base_item, compare_item, base_tracks=matching_tracks, compare_tracks=matching_tracks
687 )
688 == compare.AlbumMatchEvidence.MATCH
689 )
690
691 conflicting_tracks = _tracklist(10, isrc_prefix="USRC28718")
692 assert (
693 compare.compare_album_evidence(
694 base_item,
695 compare_item,
696 base_tracks=matching_tracks,
697 compare_tracks=conflicting_tracks,
698 )
699 == compare.AlbumMatchEvidence.NO_MATCH
700 )
701
702
703@pytest.mark.parametrize(
704 "conflict_version",
705 ["Live", "Remix", "Karaoke Edition", "Instrumental", "Acoustic Version", "Demo", "Cover"],
706)
707def test_compare_album_evidence_blank_vs_recording_conflict_is_no_match(
708 conflict_version: str,
709) -> None:
710 """
711 A blank version next to a recording-changing qualifier is a proven conflict.
712
713 Unlike a blank version next to plain packaging wording (remaster, deluxe, ...), a
714 missing version tag can never explain away a recording-altering qualifier on the
715 other side, so this must not be left for a tracklist to resolve.
716 """
717 base_item = _album(version="")
718 compare_item = _album(item_id="2", provider="test2", version=conflict_version)
719
720 assert (
721 compare.compare_album_evidence(base_item, compare_item)
722 == compare.AlbumMatchEvidence.NO_MATCH
723 )
724 assert compare.compare_album(base_item, compare_item) is False
725
726
727@pytest.mark.parametrize("packaging_version", ["Remaster", "Deluxe Edition", "Anniversary Edition"])
728def test_compare_album_evidence_blank_vs_packaging_edition_is_insufficient(
729 packaging_version: str,
730) -> None:
731 """A blank version next to plain packaging wording stays undecided, not a proven conflict."""
732 base_item = _album(version="")
733 compare_item = _album(item_id="2", provider="test2", version=packaging_version)
734
735 assert (
736 compare.compare_album_evidence(base_item, compare_item)
737 == compare.AlbumMatchEvidence.INSUFFICIENT
738 )
739 # the compatibility wrapper stays conservative and does not merge on insufficient evidence
740 assert compare.compare_album(base_item, compare_item) is False
741
742
743def test_compare_album_evidence_resolves_with_matching_fingerprint() -> None:
744 """Ambiguous edition wording resolves to MATCH once track fingerprints agree."""
745 base_item = _album(version="2022 Remaster")
746 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
747 base_tracks = _tracklist(14)
748 compare_tracks = _tracklist(14)
749
750 assert (
751 compare.compare_album_evidence(
752 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
753 )
754 == compare.AlbumMatchEvidence.MATCH
755 )
756
757
758def test_compare_album_evidence_resolves_with_conflicting_fingerprint() -> None:
759 """Ambiguous edition wording resolves to NO_MATCH once track fingerprints disagree."""
760 base_item = _album(version="2022 Remaster")
761 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
762 base_tracks = _tracklist(14, isrc_prefix="USRC17607")
763 compare_tracks = _tracklist(14, isrc_prefix="USRC28718")
764
765 assert (
766 compare.compare_album_evidence(
767 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
768 )
769 == compare.AlbumMatchEvidence.NO_MATCH
770 )
771
772
773def test_compare_album_evidence_ordinary_and_deluxe_track_counts_stay_distinct() -> None:
774 """An ordinary 8-track album and a 14-track edition must not be merged via fingerprints."""
775 base_item = _album(version="2022 Remaster")
776 compare_item = _album(item_id="2", provider="test2", version="Deluxe 2022 Remaster")
777 base_tracks = _tracklist(8)
778 compare_tracks = _tracklist(14)
779
780 assert (
781 compare.compare_album_evidence(
782 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
783 )
784 == compare.AlbumMatchEvidence.NO_MATCH
785 )
786
787
788def test_compare_album_evidence_fingerprint_overrides_matching_metadata() -> None:
789 """A conflicting tracklist overrides an otherwise nominally-matching album."""
790 base_item = _album(version="", year=2020)
791 compare_item = _album(item_id="2", provider="test2", version="", year=2020)
792 base_tracks = _tracklist(8)
793 compare_tracks = _tracklist(14)
794
795 assert (
796 compare.compare_album_evidence(
797 base_item, compare_item, base_tracks=base_tracks, compare_tracks=compare_tracks
798 )
799 == compare.AlbumMatchEvidence.NO_MATCH
800 )
801
802
803def test_compare_album_track_fingerprint_matching_isrc_and_duration() -> None:
804 """Equal ISRC, title and duration at every position is a confident match."""
805 base_tracks = _tracklist(3)
806 compare_tracks = _tracklist(3)
807
808 assert (
809 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
810 == compare.AlbumMatchEvidence.MATCH
811 )
812
813
814def test_compare_album_track_fingerprint_shared_isrc_without_duration_is_insufficient() -> None:
815 """A shared ISRC alone cannot confirm a match if neither side has a duration."""
816 base_tracks = [_track("1", track_number=1, isrc="USRC17607839", duration=0)]
817 compare_tracks = [_track("2", track_number=1, isrc="USRC17607839", duration=0)]
818
819 assert (
820 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
821 == compare.AlbumMatchEvidence.INSUFFICIENT
822 )
823
824
825def test_compare_album_track_fingerprint_conflicting_isrc() -> None:
826 """Conflicting ISRCs at the same position indicate a different recording/remaster."""
827 base_tracks = [_track("1", track_number=1, isrc="USRC17607839")]
828 compare_tracks = [_track("2", track_number=1, isrc="USRC28718001")]
829
830 assert (
831 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
832 == compare.AlbumMatchEvidence.NO_MATCH
833 )
834
835
836def test_compare_album_track_fingerprint_invalid_isrc_falls_back_to_title_duration() -> None:
837 """A structurally invalid ISRC is ignored, not treated as identity evidence."""
838 base_tracks = [_track("1", track_number=1, name="Track One", duration=200, isrc="NOTANISRC")]
839 matching_compare_tracks = [
840 _track("2", track_number=1, name="Track One", duration=200, isrc="ALSOINVALID")
841 ]
842
843 # both sides tag an (invalid) ISRC, but neither is structurally valid, so the
844 # comparison falls back to title/duration and still finds a match
845 assert (
846 compare.compare_album_track_fingerprint(base_tracks, matching_compare_tracks)
847 == compare.AlbumMatchEvidence.MATCH
848 )
849
850 # a genuine title conflict is still caught once the invalid ISRCs are ignored
851 conflicting_compare_tracks = [
852 _track("2", track_number=1, name="Different Track", duration=200, isrc="ALSOINVALID")
853 ]
854 assert (
855 compare.compare_album_track_fingerprint(base_tracks, conflicting_compare_tracks)
856 == compare.AlbumMatchEvidence.NO_MATCH
857 )
858
859
860def test_compare_album_track_fingerprint_title_duration_fallback() -> None:
861 """Without ISRCs, matching normalized title/version and a tight duration match."""
862 base_tracks = [_track("1", track_number=1, name="Track One", duration=200)]
863 compare_tracks = [_track("2", track_number=1, name="Track One", duration=201)]
864
865 assert (
866 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
867 == compare.AlbumMatchEvidence.MATCH
868 )
869 # a genuine duration conflict without ISRCs cannot be treated as the same recording
870 compare_tracks = [_track("2", track_number=1, name="Track One", duration=260)]
871 assert (
872 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
873 == compare.AlbumMatchEvidence.NO_MATCH
874 )
875
876
877def test_compare_album_track_fingerprint_sparse_tracks_are_insufficient() -> None:
878 """Tracks with no ISRC, title, or duration cannot support a confident decision."""
879 base_tracks = [_track("1", track_number=1, name="", duration=0)]
880 compare_tracks = [_track("2", track_number=1, name="", duration=0)]
881
882 assert (
883 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
884 == compare.AlbumMatchEvidence.INSUFFICIENT
885 )
886 # no tracklists supplied at all is equally undecided
887 assert (
888 compare.compare_album_track_fingerprint(None, None)
889 == compare.AlbumMatchEvidence.INSUFFICIENT
890 )
891 assert (
892 compare.compare_album_track_fingerprint([], []) == compare.AlbumMatchEvidence.INSUFFICIENT
893 )
894
895
896def test_compare_album_track_fingerprint_unknown_disc_layout_vs_multi_disc_is_insufficient() -> (
897 None
898):
899 """An unknown (no disc numbers reported at all) layout can't be shape-compared."""
900 # a provider that never reports disc numbers: assumed single disc by omission
901 base_tracks = [_track(str(n), disc_number=0, track_number=n) for n in range(1, 15)]
902 # the other side is a genuine 2-disc release (8 + 6 tracks)
903 compare_tracks = [_track(f"d1-{n}", disc_number=1, track_number=n) for n in range(1, 9)] + [
904 _track(f"d2-{n}", disc_number=2, track_number=n) for n in range(1, 7)
905 ]
906
907 # assuming disc 1 for the unknown side would falsely look like a shape conflict
908 # (or, worse, a false match): neither is safe, so this must stay undecided
909 assert (
910 compare.compare_album_track_fingerprint(base_tracks, compare_tracks)
911 == compare.AlbumMatchEvidence.INSUFFICIENT
912 )
913
914
915def test_album_tracks_have_positions() -> None:
916 """A trustworthy disc/track layout is recognized, an ambiguous one is not."""
917 assert compare.album_tracks_have_positions(_tracklist(10)) is True
918 assert compare.album_tracks_have_positions(None) is False
919 assert compare.album_tracks_have_positions([]) is False
920 # a missing track number makes the layout untrustworthy
921 assert compare.album_tracks_have_positions([_track("1", track_number=0)]) is False
922 # a missing disc number is treated as unknown, not silently assumed to be disc 1
923 assert compare.album_tracks_have_positions([_track("1", disc_number=0)]) is False
924 # a duplicate position cannot be trusted either
925 duplicate = [_track("1", track_number=1), _track("2", track_number=1)]
926 assert compare.album_tracks_have_positions(duplicate) is False
927
928
929def test_compare_external_ids_checks_all_unique_values() -> None:
930 """One mismatching unique identifier does not hide another matching value."""
931 base_ids = {
932 (ExternalID.MB_ALBUM, "11111111-1111-1111-1111-111111111111"),
933 (ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222"),
934 }
935 compare_ids = {(ExternalID.MB_ALBUM, "22222222-2222-2222-2222-222222222222")}
936
937 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.MB_ALBUM) is True
938
939
940def test_compare_external_ids_truncated_barcode_matches_full_value() -> None:
941 """A truncated 13-digit GTIN-14 (Qobuz) matches another provider's full barcode."""
942 base_ids = {(ExternalID.BARCODE, "0060252758365")}
943 compare_ids = {(ExternalID.BARCODE, "00602527583655")}
944
945 assert compare.compare_external_ids(base_ids, compare_ids, ExternalID.BARCODE) is True
946
947
948def test_compare_track() -> None: # noqa: PLR0915
949 """Test track comparison."""
950 track_a = media_items.Track(
951 item_id="1",
952 provider="test1",
953 name="Track A",
954 provider_mappings={
955 media_items.ProviderMapping(
956 item_id="1", provider_domain="test", provider_instance="test1"
957 )
958 },
959 artists=media_items.UniqueList(
960 [
961 media_items.Artist(
962 item_id="1",
963 provider="test1",
964 name="Artist A",
965 provider_mappings={
966 media_items.ProviderMapping(
967 item_id="1", provider_domain="test", provider_instance="test1"
968 )
969 },
970 )
971 ]
972 ),
973 )
974 track_b = media_items.Track(
975 item_id="1",
976 provider="test2",
977 name="Track A",
978 provider_mappings={
979 media_items.ProviderMapping(
980 item_id="2", provider_domain="test", provider_instance="test2"
981 )
982 },
983 artists=media_items.UniqueList(
984 [
985 media_items.Artist(
986 item_id="1",
987 provider="test1",
988 name="Artist A",
989 provider_mappings={
990 media_items.ProviderMapping(
991 item_id="1", provider_domain="test", provider_instance="test1"
992 )
993 },
994 )
995 ]
996 ),
997 )
998 # test match on name match
999 assert compare.compare_track(track_a, track_b) is True
1000 # test match on name mismatch
1001 track_b.name = "Track B"
1002 assert compare.compare_track(track_a, track_b) is False
1003 # test on version mismatch
1004 track_b.name = track_a.name
1005 track_b.version = "Deluxe"
1006 assert compare.compare_track(track_a, track_b) is False
1007 track_b.version = "Remix"
1008 assert compare.compare_track(track_a, track_b) is False
1009 # test on version mismatch
1010 track_b.name = track_a.name
1011 track_a.version = ""
1012 track_b.version = "Remaster"
1013 assert compare.compare_track(track_a, track_b) is False
1014 track_b.version = "Remix"
1015 assert compare.compare_track(track_a, track_b) is False
1016 # test on version match
1017 track_b.name = track_a.name
1018 track_a.version = "Deluxe"
1019 track_b.version = "Deluxe Edition"
1020 assert compare.compare_track(track_a, track_b) is True
1021 # test on exact item_id match
1022 track_b.item_id = track_a.item_id
1023 track_b.provider = track_a.provider
1024 assert compare.compare_track(track_a, track_b) is True
1025 # test on external id match
1026 track_b.name = "Track B"
1027 track_b.item_id = "2"
1028 track_b.provider = "test2"
1029 track_a.external_ids = {(ExternalID.MB_RECORDING, "123")}
1030 track_b.external_ids = track_a.external_ids
1031 assert compare.compare_track(track_a, track_b) is True
1032 # test on external id mismatch
1033 track_b.name = track_a.name
1034 track_b.external_ids = {(ExternalID.MB_RECORDING, "1234")}
1035 assert compare.compare_track(track_a, track_b) is False
1036 track_a.external_ids = set()
1037 track_b.external_ids = set()
1038 # fail on artist mismatch
1039 track_a.artists = media_items.UniqueList(
1040 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
1041 )
1042 track_b.artists = media_items.UniqueList(
1043 [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
1044 )
1045 assert compare.compare_track(track_a, track_b) is False
1046 # pass on partial artist match (if first artist matches)
1047 track_a.artists = media_items.UniqueList(
1048 [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
1049 )
1050 track_b.artists = media_items.UniqueList(
1051 [
1052 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
1053 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
1054 ]
1055 )
1056 assert compare.compare_track(track_a, track_b) is True
1057 # fail on partial artist match in strict mode
1058 track_b.artists = media_items.UniqueList(
1059 [
1060 media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
1061 media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
1062 ]
1063 )
1064 assert compare.compare_track(track_a, track_b) is False
1065 # partial artist match is allowed in non-strict mode
1066 assert compare.compare_track(track_a, track_b, False) is True
1067 track_b.artists = track_a.artists
1068 # fail on album mismatch
1069 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1070 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
1071 assert compare.compare_track(track_a, track_b) is False
1072 # pass on exact album(track) match (regardless duration)
1073 track_b.album = track_a.album
1074 track_a.disc_number = 1
1075 track_a.track_number = 1
1076 track_b.disc_number = track_a.disc_number
1077 track_b.track_number = track_a.track_number
1078 track_a.duration = 300
1079 track_b.duration = 310
1080 assert compare.compare_track(track_a, track_b) is True
1081 # pass on album(track) mismatch
1082 track_b.album = track_a.album
1083 track_a.disc_number = 1
1084 track_a.track_number = 1
1085 track_b.disc_number = track_a.disc_number
1086 track_b.track_number = 2
1087 track_b.duration = track_a.duration
1088 assert compare.compare_track(track_a, track_b) is False
1089 # test special case - ISRC match but MusicBrainz ID mismatch
1090 # this can happen for some classical music albums
1091 track_a.external_ids = {
1092 (ExternalID.ISRC, "123"),
1093 (ExternalID.MB_RECORDING, "abc"),
1094 }
1095 track_b.external_ids = {
1096 (ExternalID.ISRC, "123"),
1097 (ExternalID.MB_RECORDING, "abcd"),
1098 }
1099 assert compare.compare_track(track_a, track_b) is False
1100 # test multi-disc: same album, same external IDs, different disc numbers should NOT match
1101 track_a.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
1102 track_b.external_ids = {(ExternalID.MB_RECORDING, "same-recording-id")}
1103 track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1104 track_b.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
1105 track_a.disc_number = 1
1106 track_b.disc_number = 2
1107 track_a.track_number = 3
1108 track_b.track_number = 3
1109 assert compare.compare_track(track_a, track_b) is False
1110 # same disc number should still match via external ID
1111 track_b.disc_number = 1
1112 assert compare.compare_track(track_a, track_b) is True
1113 # different disc but different albums should still match via external ID
1114 track_b.disc_number = 2
1115 track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
1116 assert compare.compare_track(track_a, track_b) is True
1117
1118
1119def test_compare_track_missing_disc_number_assumes_disc_one() -> None:
1120 """A track without a disc number tag still makes the exact albumtrack match on disc 1."""
1121
1122 def _albumtrack(item_id: str, provider: str, disc_number: int) -> media_items.Track:
1123 return media_items.Track(
1124 item_id=item_id,
1125 provider=provider,
1126 name="Track A",
1127 duration=300,
1128 disc_number=disc_number,
1129 track_number=5,
1130 artists=media_items.UniqueList(
1131 [media_items.ItemMapping(item_id="1", provider=provider, name="Artist A")]
1132 ),
1133 album=media_items.ItemMapping(item_id="1", provider=provider, name="Album A"),
1134 provider_mappings={
1135 media_items.ProviderMapping(
1136 item_id=item_id, provider_domain=provider, provider_instance=provider
1137 )
1138 },
1139 )
1140
1141 untagged = _albumtrack("1", "test1", disc_number=0)
1142 disc_one = _albumtrack("2", "test2", disc_number=1)
1143 # durations differ beyond every fallback tolerance: only the albumtrack path can match
1144 disc_one.duration = 320
1145 assert compare.compare_track(untagged, disc_one) is True
1146
1147 # an unknown disc number only ever assumes disc 1, never a higher disc
1148 disc_two = _albumtrack("3", "test2", disc_number=2)
1149 disc_two.duration = 320
1150 assert compare.compare_track(untagged, disc_two) is False
1151
1152
1153def test_compare_strings_accent_drift_matches() -> None:
1154 """Diacritics do not distinguish two otherwise identical names."""
1155 assert compare.compare_strings("Sigur R\u00f3s", "Sigur Ros") is True
1156 assert compare.compare_strings("C\u00e9line Dion", "Celine Dion") is True
1157 assert compare.compare_strings("Bj\u00f6rk", "Bjork") is True
1158
1159
1160def test_compare_strings_punctuation_and_spacing_drift_matches() -> None:
1161 """Apostrophe, punctuation and spacing drift does not distinguish two names."""
1162 assert compare.compare_strings("Jane's Addiction", "Jane\u2019s Addiction") is True
1163 assert compare.compare_strings("A.C. Newman", "AC Newman") is True
1164 assert compare.compare_strings("All-4-One", "All4One") is True
1165
1166
1167def test_compare_strings_symbol_only_names_stay_distinct() -> None:
1168 """Names that normalize to nothing are compared raw, so only real duplicates match."""
1169 assert compare.compare_strings("!!!", "...") is False
1170 # spacing drift within such a name is still the same name
1171 assert compare.compare_strings("( )", "()") is True
1172 # a name that normalizes to nothing is never the same as one that doesn't
1173 assert compare.compare_strings("!!!", "Chk Chk Chk") is False
1174
1175
1176def test_compare_strings_normalization_does_not_leak_into_fuzzy() -> None:
1177 """The non-strict path keeps its own (more lenient) verdicts."""
1178 assert compare.compare_strings("!!!", "...", strict=False) is True
1179
1180
1181def test_compare_strings_case_insensitive_fuzzy() -> None:
1182 """Test that non-strict fuzzy matching is fully case-insensitive."""
1183 # These differ slightly ("Feat." vs "FT.") so create_safe_string won't match,
1184 # falling through to SequenceMatcher which must compare both strings lowered.
1185 assert compare.compare_strings("Track Feat. John", "TRACK FT. JOHN", strict=False) is True
1186
1187
1188def test_loose_compare_strings_containment_both_directions() -> None:
1189 """Partial containment matches regardless of which side has the extra wording."""
1190 assert compare.loose_compare_strings("Some Track", "Some Track (Acoustic)") is True
1191 assert compare.loose_compare_strings("Some Track (Acoustic)", "Some Track") is True
1192
1193
1194def test_compare_radio() -> None:
1195 """Test the radio compare helper."""
1196
1197 def _radio(
1198 item_id: str, provider: str, name: str, *, is_dynamic: bool = False
1199 ) -> media_items.Radio:
1200 return media_items.Radio(
1201 item_id=item_id,
1202 provider=provider,
1203 name=name,
1204 is_dynamic=is_dynamic,
1205 provider_mappings={
1206 media_items.ProviderMapping(
1207 item_id=item_id, provider_domain=provider, provider_instance=provider
1208 )
1209 },
1210 )
1211
1212 live_a = _radio("a", "tunein", "Chill Vibes")
1213 live_b = _radio("b", "radiobrowser", "Chill Vibes")
1214 # a live station is matched across providers on its name
1215 assert compare.compare_radio(live_a, live_b) is True
1216
1217 station = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1218 # a dynamic station only exists on its own provider, so the name is not enough
1219 assert compare.compare_radio(station, live_a) is False
1220 assert compare.compare_radio(live_a, station) is False
1221 # ... but it is still recognised as itself
1222 same = _radio("c", "pandora", "Chill Vibes", is_dynamic=True)
1223 assert compare.compare_radio(station, same) is True
1224