/
/
1"""Several helper/utils to compare objects."""
2
3from __future__ import annotations
4
5import re
6from collections.abc import Sequence
7from difflib import SequenceMatcher
8from enum import Enum
9from functools import lru_cache
10
11from music_assistant_models.enums import ExternalID, MediaType
12from music_assistant_models.helpers import create_safe_string
13from music_assistant_models.media_items import (
14 Album,
15 Artist,
16 Audiobook,
17 ItemMapping,
18 MediaItem,
19 MediaItemMetadata,
20 MediaItemType,
21 Playlist,
22 Podcast,
23 Radio,
24 Track,
25)
26
27from music_assistant.helpers.external_ids import is_valid_isrc, normalize_external_id
28
29IGNORE_VERSIONS = (
30 "explicit", # explicit is matched separately
31 "music from and inspired by the motion picture",
32 "original soundtrack",
33 "hi-res", # quality is handled separately
34)
35
36_VERSION_IGNORE_WORDS = {
37 "album",
38 "at",
39 "edition",
40 "variant",
41 "versie",
42 "version",
43 "versione",
44}
45_VERSION_WORD_ALIASES = {
46 "remastered": "remaster",
47}
48_IGNORE_VERSION_KEYS = {create_safe_string(value) for value in IGNORE_VERSIONS}
49
50# version tokens that signal a fundamentally different recording (not just packaging),
51# so they must never be treated as an ambiguous/mergeable edition difference
52_RECORDING_CONFLICT_VERSION_TOKENS = {
53 "acoustic",
54 "cover",
55 "demo",
56 "instrumental",
57 "karaoke",
58 "live",
59 "remix",
60 "session",
61}
62
63# duration tolerances (seconds) for the album-track fingerprint comparison
64_ISRC_DURATION_TOLERANCE = 8
65_FALLBACK_DURATION_TOLERANCE = 2
66
67
68class AlbumMatchEvidence(Enum):
69 """Confidence level for an album identity comparison."""
70
71 MATCH = "match"
72 NO_MATCH = "no_match"
73 INSUFFICIENT = "insufficient"
74
75
76def compare_media_item(
77 base_item: MediaItemType | ItemMapping,
78 compare_item: MediaItemType | ItemMapping,
79 strict: bool = True,
80) -> bool | None:
81 """Compare two media items and return True if they match."""
82 if base_item.media_type == MediaType.ARTIST and compare_item.media_type == MediaType.ARTIST:
83 assert isinstance(base_item, Artist | ItemMapping) # for type checking
84 assert isinstance(compare_item, Artist | ItemMapping) # for type checking
85 return compare_artist(base_item, compare_item, strict)
86 if base_item.media_type == MediaType.ALBUM and compare_item.media_type == MediaType.ALBUM:
87 assert isinstance(base_item, Album | ItemMapping) # for type checking
88 assert isinstance(compare_item, Album | ItemMapping) # for type checking
89 return compare_album(base_item, compare_item, strict)
90 if base_item.media_type == MediaType.TRACK and compare_item.media_type == MediaType.TRACK:
91 assert isinstance(base_item, Track) # for type checking
92 assert isinstance(compare_item, Track) # for type checking
93 return compare_track(base_item, compare_item, strict)
94 if base_item.media_type == MediaType.PLAYLIST and compare_item.media_type == MediaType.PLAYLIST:
95 assert isinstance(base_item, Playlist | ItemMapping) # for type checking
96 assert isinstance(compare_item, Playlist | ItemMapping) # for type checking
97 return compare_playlist(base_item, compare_item, strict)
98 if base_item.media_type == MediaType.RADIO and compare_item.media_type == MediaType.RADIO:
99 assert isinstance(base_item, Radio | ItemMapping) # for type checking
100 assert isinstance(compare_item, Radio | ItemMapping) # for type checking
101 return compare_radio(base_item, compare_item, strict)
102 if (
103 base_item.media_type == MediaType.AUDIOBOOK
104 and compare_item.media_type == MediaType.AUDIOBOOK
105 ):
106 assert isinstance(base_item, Audiobook | ItemMapping) # for type checking
107 assert isinstance(compare_item, Audiobook | ItemMapping) # for type checking
108 return compare_audiobook(base_item, compare_item, strict)
109 if base_item.media_type == MediaType.PODCAST and compare_item.media_type == MediaType.PODCAST:
110 assert isinstance(base_item, Podcast | ItemMapping) # for type checking
111 assert isinstance(compare_item, Podcast | ItemMapping) # for type checking
112 return compare_podcast(base_item, compare_item, strict)
113 assert isinstance(base_item, ItemMapping) # for type checking
114 assert isinstance(compare_item, ItemMapping) # for type checking
115 return compare_item_mapping(base_item, compare_item, strict)
116
117
118def compare_artist(
119 base_item: Artist | ItemMapping,
120 compare_item: Artist | ItemMapping,
121 strict: bool = True,
122) -> bool | None:
123 """Compare two artist items and return True if they match."""
124 # return early on exact item_id match
125 if compare_item_ids(base_item, compare_item):
126 return True
127 # return early on (un)matched external id
128 for ext_id in (ExternalID.MB_ARTIST, ExternalID.DISCOGS, ExternalID.TADB):
129 external_id_match = compare_external_ids(
130 base_item.external_ids, compare_item.external_ids, ext_id
131 )
132 if external_id_match is not None:
133 return external_id_match
134 # return early if artist_types don't match
135 if (
136 isinstance(base_item, Artist)
137 and isinstance(compare_item, Artist)
138 and base_item.artist_type != compare_item.artist_type
139 ):
140 return False
141 # finally comparing on (exact) name match
142 return compare_strings(base_item.name, compare_item.name, strict=strict)
143
144
145def compare_album(
146 base_item: Album | ItemMapping,
147 compare_item: Album | ItemMapping,
148 strict: bool = True,
149) -> bool | None:
150 """Compare two album items and return True if they match."""
151 return compare_album_evidence(base_item, compare_item, strict) == AlbumMatchEvidence.MATCH
152
153
154def compare_album_evidence(
155 base_item: Album | ItemMapping,
156 compare_item: Album | ItemMapping,
157 strict: bool = True,
158 base_tracks: Sequence[Track] | None = None,
159 compare_tracks: Sequence[Track] | None = None,
160) -> AlbumMatchEvidence:
161 """
162 Return the match evidence for two album items.
163
164 Unlike `compare_album`, this distinguishes a confident non-match from
165 insufficient metadata (e.g. an edition difference that cannot be resolved from
166 the album's own fields), so a caller that can fetch tracklists knows when doing
167 so may still resolve the comparison. If `base_tracks`/`compare_tracks` are
168 supplied, an ordered track fingerprint comparison is used to resolve that
169 remaining ambiguity, and a conflicting fingerprint overrides an otherwise
170 nominally-matching album (e.g. identical title/version/year but a different
171 number of tracks).
172
173 :param base_tracks: Ordered tracklist for base_item, if already available to the caller.
174 :param compare_tracks: Ordered tracklist for compare_item, if already available.
175 """
176 # return early on exact item_id match
177 if compare_item_ids(base_item, compare_item):
178 return AlbumMatchEvidence.MATCH
179
180 # return early on (un)matched authoritative external id
181 for ext_id in (
182 ExternalID.MB_ALBUM,
183 ExternalID.DISCOGS,
184 ExternalID.TADB,
185 ):
186 external_id_match = compare_external_ids(
187 base_item.external_ids, compare_item.external_ids, ext_id
188 )
189 if external_id_match is not None:
190 return AlbumMatchEvidence.MATCH if external_id_match else AlbumMatchEvidence.NO_MATCH
191
192 # barcode/ASIN are shared across pressings and are non-unique corroboration only,
193 # so they are never used on their own, only to corroborate a year mismatch below
194 secondary_external_id_match = any(
195 compare_external_ids(base_item.external_ids, compare_item.external_ids, ext_id) is True
196 for ext_id in (ExternalID.ASIN, ExternalID.BARCODE)
197 )
198
199 # a real edition conflict (e.g. deluxe vs. live) is decisive, an ambiguous
200 # subset/superset wording (e.g. "2022 Remaster" vs "Deluxe 2022 Remaster") is not
201 version_evidence = _compare_album_version(base_item.version, compare_item.version)
202 if version_evidence == AlbumMatchEvidence.NO_MATCH:
203 return AlbumMatchEvidence.NO_MATCH
204 # compare name
205 if not _compare_album_name(base_item.name, compare_item.name):
206 return AlbumMatchEvidence.NO_MATCH
207
208 ambiguous = version_evidence == AlbumMatchEvidence.INSUFFICIENT
209 if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
210 return _finalize_album_evidence(ambiguous, base_tracks, compare_tracks)
211 # for strict matching we REQUIRE both items to be a real album object
212 assert isinstance(base_item, Album)
213 assert isinstance(compare_item, Album)
214 # compare year: without corroboration this is provider drift, not proof either way
215 if (
216 base_item.year
217 and compare_item.year
218 and base_item.year != compare_item.year
219 and not secondary_external_id_match
220 ):
221 ambiguous = True
222 # compare explicitness
223 if compare_explicit(base_item.metadata, compare_item.metadata) is False:
224 return AlbumMatchEvidence.NO_MATCH
225 # compare album artist(s)
226 if not compare_artists(base_item.artists, compare_item.artists, not strict):
227 return AlbumMatchEvidence.NO_MATCH
228 return _finalize_album_evidence(ambiguous, base_tracks, compare_tracks)
229
230
231def compare_album_track_fingerprint(
232 base_tracks: Sequence[Track] | None,
233 compare_tracks: Sequence[Track] | None,
234) -> AlbumMatchEvidence:
235 """
236 Compare two album tracklists position-by-position and return match evidence.
237
238 Requires an identical disc/track shape to consider two tracklists the same
239 edition; a tracklist that never reports a disc number is treated as insufficient
240 (not assumed disc 1) when compared against a genuinely multi-disc tracklist. At
241 each position, a shared (normalized) ISRC with a compatible duration is preferred
242 as identity evidence; conflicting ISRCs indicate a different recording/remaster.
243 Positions without a usable ISRC on either side fall back to a normalized
244 title/version match with a tight duration tolerance.
245
246 :param base_tracks: Ordered tracklist for the base album.
247 :param compare_tracks: Ordered tracklist for the album being compared.
248 """
249 if not base_tracks or not compare_tracks:
250 return AlbumMatchEvidence.INSUFFICIENT
251 base_positions = _track_positions(base_tracks)
252 compare_positions = _track_positions(compare_tracks)
253 if not base_positions or not compare_positions:
254 return AlbumMatchEvidence.INSUFFICIENT
255 base_is_multi_disc = any(disc_number > 1 for disc_number, _ in base_positions)
256 compare_is_multi_disc = any(disc_number > 1 for disc_number, _ in compare_positions)
257 if (base_is_multi_disc and _has_unknown_disc_layout(compare_tracks)) or (
258 compare_is_multi_disc and _has_unknown_disc_layout(base_tracks)
259 ):
260 # one side never reports a disc number while the other is genuinely multi-disc:
261 # assuming disc 1 for the unknown side would produce a false shape conflict
262 return AlbumMatchEvidence.INSUFFICIENT
263 if base_positions.keys() != compare_positions.keys():
264 # different disc/track shape (e.g. a bonus disc or missing tracks): different edition
265 return AlbumMatchEvidence.NO_MATCH
266
267 evidence = AlbumMatchEvidence.MATCH
268 for position, base_track in base_positions.items():
269 position_evidence = _compare_track_fingerprint(base_track, compare_positions[position])
270 if position_evidence == AlbumMatchEvidence.NO_MATCH:
271 return AlbumMatchEvidence.NO_MATCH
272 if position_evidence == AlbumMatchEvidence.INSUFFICIENT:
273 evidence = AlbumMatchEvidence.INSUFFICIENT
274 return evidence
275
276
277def album_tracks_have_positions(tracks: Sequence[Track] | None) -> bool:
278 """
279 Return True if a tracklist has a trustworthy, unambiguous disc/track layout.
280
281 A caller choosing a base tracklist for album-track fingerprinting can use this to
282 reject a tracklist whose positions cannot be trusted (a missing disc or track number,
283 or a duplicate position) and fall back to another source instead.
284
285 :param tracks: Tracklist to inspect.
286 """
287 if not tracks:
288 return False
289 # a missing disc or track number is treated as unknown rather than silently assumed,
290 # so such a tracklist is not trusted as a shape reference
291 if any(not track.disc_number or not track.track_number for track in tracks):
292 return False
293 return bool(_track_positions(tracks))
294
295
296def compare_track(
297 base_item: Track,
298 compare_item: Track,
299 strict: bool = True,
300 track_albums: list[Album] | None = None,
301) -> bool:
302 """Compare two track items and return True if they match."""
303 # return early on exact item_id match
304 if compare_item_ids(base_item, compare_item):
305 return True
306 # tracks on the same album but different discs are always distinct,
307 # even if they share external IDs (e.g. same recording on multiple discs)
308 if (
309 base_item.album
310 and compare_item.album
311 and base_item.disc_number
312 and compare_item.disc_number
313 and base_item.disc_number != compare_item.disc_number
314 and compare_album(base_item.album, compare_item.album, False)
315 ):
316 return False
317 # return early on (un)matched primary/unique external id
318 for ext_id in (
319 ExternalID.MB_RECORDING,
320 ExternalID.MB_TRACK,
321 ExternalID.ACOUSTID,
322 ):
323 external_id_match = compare_external_ids(
324 base_item.external_ids, compare_item.external_ids, ext_id
325 )
326 if external_id_match is not None:
327 return external_id_match
328 # check secondary external id matches
329 for ext_id in (
330 ExternalID.DISCOGS,
331 ExternalID.TADB,
332 ExternalID.ISRC,
333 ExternalID.ASIN,
334 ):
335 external_id_match = compare_external_ids(
336 base_item.external_ids, compare_item.external_ids, ext_id
337 )
338 if external_id_match is True:
339 # we got a 'soft-match' on a secondary external id (like ISRC)
340 # but we do a double check on duration
341 if abs(base_item.duration - compare_item.duration) <= 8:
342 return True
343
344 # compare name
345 if not compare_strings(base_item.name, compare_item.name, strict=True):
346 return False
347 # track artist(s) must match
348 if not compare_artists(base_item.artists, compare_item.artists, any_match=not strict):
349 return False
350 # track version must match
351 if strict and not compare_version(base_item.version, compare_item.version):
352 return False
353 # check if both tracks are (not) explicit
354 if base_item.metadata.explicit is None and isinstance(base_item.album, Album):
355 base_item.metadata.explicit = base_item.album.metadata.explicit
356 if compare_item.metadata.explicit is None and isinstance(compare_item.album, Album):
357 compare_item.metadata.explicit = compare_item.album.metadata.explicit
358 if strict and compare_explicit(base_item.metadata, compare_item.metadata) is False:
359 return False
360
361 # exact albumtrack match = 100% match
362 if (
363 base_item.album
364 and compare_item.album
365 and compare_album(base_item.album, compare_item.album, False)
366 and base_item.disc_number
367 and compare_item.disc_number
368 and base_item.track_number
369 and compare_item.track_number
370 and base_item.disc_number == compare_item.disc_number
371 and base_item.track_number == compare_item.track_number
372 ):
373 return True
374
375 # fallback: exact album match and (near-exact) track duration match
376 if (
377 base_item.album is not None
378 and compare_item.album is not None
379 and (base_item.track_number == 0 or compare_item.track_number == 0)
380 and compare_album(base_item.album, compare_item.album, False)
381 and abs(base_item.duration - compare_item.duration) <= 3
382 ):
383 return True
384
385 # fallback: additional compare albums provided for base track
386 if (
387 compare_item.album is not None
388 and track_albums
389 and abs(base_item.duration - compare_item.duration) <= 3
390 ):
391 for track_album in track_albums:
392 if compare_album(track_album, compare_item.album, False):
393 return True
394
395 # fallback edge case: albumless track with same duration
396 if (
397 base_item.album is None
398 and compare_item.album is None
399 and base_item.disc_number == 0
400 and compare_item.disc_number == 0
401 and base_item.track_number == 0
402 and compare_item.track_number == 0
403 and base_item.duration == compare_item.duration
404 ):
405 return True
406
407 if strict:
408 # in strict mode, we require an exact album match so return False here
409 return False
410
411 # Accept last resort (in non strict mode): (near) exact duration,
412 # otherwise fail all other cases.
413 # Note that as this stage, all other info already matches,
414 # such as title, artist etc.
415 return abs(base_item.duration - compare_item.duration) <= 2
416
417
418def compare_playlist(
419 base_item: Playlist | ItemMapping,
420 compare_item: Playlist | ItemMapping,
421 strict: bool = True,
422) -> bool | None:
423 """Compare two Playlist items and return True if they match."""
424 # require (exact) name match
425 if not compare_strings(base_item.name, compare_item.name, strict=strict):
426 return False
427 # require exact owner match (if not ItemMapping)
428 if isinstance(base_item, Playlist) and isinstance(compare_item, Playlist):
429 if not compare_strings(base_item.owner, compare_item.owner):
430 return False
431 # a playlist is always unique - so do a strict compare on item id(s)
432 return compare_item_ids(base_item, compare_item)
433
434
435def compare_radio(
436 base_item: Radio | ItemMapping,
437 compare_item: Radio | ItemMapping,
438 strict: bool = True,
439) -> bool | None:
440 """Compare two Radio items and return True if they match."""
441 # return early on exact item_id match
442 if compare_item_ids(base_item, compare_item):
443 return True
444 # a dynamic station is its provider's own, so a same-named station is a different one
445 if _is_dynamic_radio(base_item) or _is_dynamic_radio(compare_item):
446 return False
447 # compare version
448 if not compare_version(base_item.version, compare_item.version):
449 return False
450 # finally comparing on (exact) name match
451 return compare_strings(base_item.name, compare_item.name, strict=strict)
452
453
454def compare_audiobook(
455 base_item: Audiobook | ItemMapping,
456 compare_item: Audiobook | ItemMapping,
457 strict: bool = True,
458) -> bool | None:
459 """Compare two Audiobook items and return True if they match."""
460 # return early on exact item_id match
461 if compare_item_ids(base_item, compare_item):
462 return True
463
464 # return early on (un)matched external id
465 for ext_id in (
466 ExternalID.ASIN,
467 ExternalID.BARCODE,
468 ):
469 external_id_match = compare_external_ids(
470 base_item.external_ids, compare_item.external_ids, ext_id
471 )
472 if external_id_match is not None:
473 return external_id_match
474
475 # compare version
476 if not compare_version(base_item.version, compare_item.version):
477 return False
478 # compare name
479 if not compare_strings(base_item.name, compare_item.name, strict=True):
480 return False
481 if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
482 return True
483 # for strict matching we REQUIRE both items to be a real Audiobook object
484 assert isinstance(base_item, Audiobook)
485 assert isinstance(compare_item, Audiobook)
486 # compare publisher
487 if (
488 base_item.publisher
489 and compare_item.publisher
490 and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
491 ):
492 return False
493
494 def _audiobook_artist_name(value: str | Artist | ItemMapping) -> str:
495 return value.name if isinstance(value, Artist | ItemMapping) else value
496
497 # compare narrator(s) — different narrators indicate different recordings and must not be merged
498 if base_item.narrators and compare_item.narrators:
499 base_narrators = {
500 create_safe_string(_audiobook_artist_name(n)) for n in base_item.narrators
501 }
502 compare_narrators = {
503 create_safe_string(_audiobook_artist_name(n)) for n in compare_item.narrators
504 }
505 if base_narrators.isdisjoint(compare_narrators):
506 return False
507 # compare author(s)
508 for author in base_item.authors:
509 author_safe = create_safe_string(_audiobook_artist_name(author))
510 if author_safe in [
511 create_safe_string(_audiobook_artist_name(x)) for x in compare_item.authors
512 ]:
513 return True
514 return False
515
516
517def compare_podcast(
518 base_item: Podcast | ItemMapping,
519 compare_item: Podcast | ItemMapping,
520 strict: bool = True,
521) -> bool | None:
522 """Compare two Podcast items and return True if they match."""
523 # return early on exact item_id match
524 if compare_item_ids(base_item, compare_item):
525 return True
526
527 # return early on (un)matched external id
528 for ext_id in (
529 ExternalID.ASIN,
530 ExternalID.BARCODE,
531 ):
532 external_id_match = compare_external_ids(
533 base_item.external_ids, compare_item.external_ids, ext_id
534 )
535 if external_id_match is not None:
536 return external_id_match
537
538 # compare version
539 if not compare_version(base_item.version, compare_item.version):
540 return False
541 # compare name
542 if not compare_strings(base_item.name, compare_item.name, strict=True):
543 return False
544 if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
545 return True
546 # for strict matching we REQUIRE both items to be a real Podcast object
547 assert isinstance(base_item, Podcast)
548 assert isinstance(compare_item, Podcast)
549 # compare publisher
550 return not (
551 base_item.publisher
552 and compare_item.publisher
553 and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
554 )
555
556
557def compare_item_mapping(
558 base_item: ItemMapping,
559 compare_item: ItemMapping,
560 strict: bool = True,
561) -> bool | None:
562 """Compare two ItemMapping items and return True if they match."""
563 # return early on exact item_id match
564 if compare_item_ids(base_item, compare_item):
565 return True
566 # return early on (un)matched external id
567 # check all ExternalID, as ItemMapping is a minimized obj for all MediaItems
568 for ext_id in ExternalID:
569 external_id_match = compare_external_ids(
570 base_item.external_ids, compare_item.external_ids, ext_id
571 )
572 if external_id_match is not None:
573 return external_id_match
574 # compare version
575 if not compare_version(base_item.version, compare_item.version):
576 return False
577 # finally comparing on (exact) name match
578 return compare_strings(base_item.name, compare_item.name, strict=strict)
579
580
581def compare_artists(
582 base_items: list[Artist | ItemMapping],
583 compare_items: list[Artist | ItemMapping],
584 any_match: bool = True,
585) -> bool:
586 """Compare two lists of artist and return True if both lists match (exactly)."""
587 if not base_items or not compare_items:
588 return False
589 # match if first artist matches in both lists
590 if compare_artist(base_items[0], compare_items[0]):
591 return True
592 # compare the artist lists
593 matches = 0
594 for base_item in base_items:
595 for compare_item in compare_items:
596 if compare_artist(base_item, compare_item):
597 if any_match:
598 return True
599 matches += 1
600 return len(base_items) == len(compare_items) == matches
601
602
603def compare_albums(
604 base_items: list[Album | ItemMapping],
605 compare_items: list[Album | ItemMapping],
606 any_match: bool = True,
607) -> bool:
608 """Compare two lists of albums and return True if a match was found."""
609 matches = 0
610 for base_item in base_items:
611 for compare_item in compare_items:
612 if compare_album(base_item, compare_item):
613 if any_match:
614 return True
615 matches += 1
616 return len(base_items) == matches
617
618
619def compare_item_ids(
620 base_item: MediaItem | ItemMapping, compare_item: MediaItem | ItemMapping
621) -> bool:
622 """Compare item_id(s) of two media items."""
623 if not base_item.provider or not compare_item.provider:
624 return False
625 if not base_item.item_id or not compare_item.item_id:
626 return False
627 if base_item.provider == compare_item.provider and base_item.item_id == compare_item.item_id:
628 return True
629
630 base_prov_ids = getattr(base_item, "provider_mappings", None)
631 compare_prov_ids = getattr(compare_item, "provider_mappings", None)
632
633 if base_prov_ids is not None:
634 assert isinstance(base_item, MediaItem) # for type checking
635 for prov_l in base_item.provider_mappings:
636 if (
637 prov_l.provider_instance == compare_item.provider
638 and prov_l.item_id == compare_item.item_id
639 ):
640 return True
641
642 if compare_prov_ids is not None:
643 assert isinstance(compare_item, MediaItem) # for type checking
644 for prov_r in compare_item.provider_mappings:
645 if (
646 prov_r.provider_instance == base_item.provider
647 and prov_r.item_id == base_item.item_id
648 ):
649 return True
650
651 if base_prov_ids is not None and compare_prov_ids is not None:
652 assert isinstance(base_item, MediaItem) # for type checking
653 assert isinstance(compare_item, MediaItem) # for type checking
654 for prov_l in base_item.provider_mappings:
655 for prov_r in compare_item.provider_mappings:
656 if prov_l.provider_domain != prov_r.provider_domain:
657 continue
658 if (
659 prov_l.is_unique or prov_r.is_unique
660 ) and prov_l.provider_instance != prov_r.provider_instance:
661 continue
662 if prov_l.item_id == prov_r.item_id:
663 return True
664 return False
665
666
667def compare_external_ids(
668 external_ids_base: set[tuple[ExternalID, str]],
669 external_ids_compare: set[tuple[ExternalID, str]],
670 external_id_type: ExternalID,
671) -> bool | None:
672 """Compare external ids and return True if a match was found."""
673 base_ids = {
674 normalize_external_id(external_id_type, value)
675 for current_type, value in external_ids_base
676 if current_type == external_id_type
677 }
678 if not base_ids:
679 # return early if the requested external id type is not present in the base set
680 return None
681 compare_ids = {
682 normalize_external_id(external_id_type, value)
683 for current_type, value in external_ids_compare
684 if current_type == external_id_type
685 }
686 if not compare_ids:
687 # return early if the requested external id type is not present in the compare set
688 return None
689 if base_ids.intersection(compare_ids):
690 return True
691 if external_id_type.is_unique:
692 return False
693 return None
694
695
696def loose_compare_strings(base: str, alt: str) -> bool:
697 """Compare strings and return True even on partial match."""
698 # this is used to display 'versions' of the same track/album
699 # where we account for other spelling or some additional wording in the title
700 if len(base) <= 3 or len(alt) <= 3:
701 return compare_strings(base, alt, True)
702 word_count = len(base.strip().split(" "))
703 if word_count == 1 and len(base) < 10:
704 return compare_strings(base, alt, False)
705 base_comp = create_safe_string(base)
706 alt_comp = create_safe_string(alt)
707 if base_comp in alt_comp:
708 return True
709 return base_comp in alt_comp
710
711
712def compare_strings(str1: str, str2: str, strict: bool = True) -> bool:
713 """Compare strings and return True if we have an (almost) perfect match."""
714 if not str1 or not str2:
715 return False
716 str1_lower = str1.lower()
717 str2_lower = str2.lower()
718 if strict:
719 return str1_lower == str2_lower
720 # return early if total length mismatch
721 if abs(len(str1) - len(str2)) > 4:
722 return False
723 # handle '&' vs 'And'
724 if " & " in str1_lower and " and " in str2_lower:
725 str2 = str2_lower.replace(" and ", " & ")
726 elif " and " in str1_lower and " & " in str2:
727 str2 = str2_lower.replace(" & ", " and ")
728 if create_safe_string(str1) == create_safe_string(str2):
729 return True
730 # last resort: use difflib to compare strings
731 required_accuracy = 0.9 if (len(str1) + len(str2)) > 18 else 0.8
732 return SequenceMatcher(a=str1_lower, b=str2_lower).ratio() > required_accuracy
733
734
735def compare_version(base_version: str, compare_version: str) -> bool:
736 """Compare version string."""
737 return _normalize_version_tokens(base_version) == _normalize_version_tokens(compare_version)
738
739
740def compare_explicit(base: MediaItemMetadata, compare: MediaItemMetadata) -> bool | None:
741 """Compare if explicit is same in metadata."""
742 if base.explicit is not None and compare.explicit is not None:
743 # explicitness info is not always present in metadata
744 # only strict compare them if both have the info set
745 return base.explicit == compare.explicit
746 return None
747
748
749@lru_cache(maxsize=1024)
750def _normalize_version_tokens(value: str) -> tuple[str, ...]:
751 """Return meaningful version tokens in stable order."""
752 if not value or create_safe_string(value) in _IGNORE_VERSION_KEYS:
753 return ()
754 tokens = (
755 _VERSION_WORD_ALIASES.get(token, token)
756 for token in re.findall(r"[^\W_]+", value.casefold())
757 )
758 return tuple(sorted(token for token in tokens if token not in _VERSION_IGNORE_WORDS))
759
760
761def _is_dynamic_radio(item: Radio | ItemMapping) -> bool:
762 """Return True if the item is a dynamic radio station."""
763 return isinstance(item, Radio) and item.is_dynamic
764
765
766def _compare_album_version(base_version: str, compare_version: str) -> AlbumMatchEvidence:
767 """Return match evidence for an album version/edition comparison."""
768 base_tokens = set(_normalize_version_tokens(base_version))
769 compare_tokens = set(_normalize_version_tokens(compare_version))
770 if base_tokens == compare_tokens:
771 return AlbumMatchEvidence.MATCH
772 # a recording-changing qualifier (live, karaoke, remix, ...) makes an otherwise
773 # unequal pair of editions unsafe to merge, wherever it appears in either wording,
774 # not only when it is the token that happens to differ between the two, and even
775 # when the other side omits version metadata entirely
776 if (base_tokens | compare_tokens) & _RECORDING_CONFLICT_VERSION_TOKENS:
777 return AlbumMatchEvidence.NO_MATCH
778 if not base_tokens or not compare_tokens:
779 # a provider commonly omits edition metadata entirely (e.g. a remaster tagged
780 # without a version string), so a blank version next to a real one is
781 # undecided rather than a proven conflict: let a tracklist resolve it
782 return AlbumMatchEvidence.INSUFFICIENT
783 if base_tokens < compare_tokens or compare_tokens < base_tokens:
784 # one version's wording is a strict subset of the other's (e.g. "2022 Remaster"
785 # vs. "Deluxe 2022 Remaster"): an ambiguous packaging difference a tracklist can resolve
786 return AlbumMatchEvidence.INSUFFICIENT
787 return AlbumMatchEvidence.NO_MATCH
788
789
790def _compare_album_name(base_name: str, compare_name: str) -> bool:
791 """Return True if two album titles are the same identity, ignoring formatting drift."""
792 base_safe = _normalize_album_name(base_name)
793 compare_safe = _normalize_album_name(compare_name)
794 if base_safe and compare_safe:
795 return base_safe == compare_safe
796 if base_safe or compare_safe:
797 return False
798 # both titles collapse to nothing under normalization (e.g. pure punctuation):
799 # fall back to a whitespace-normalized raw comparison so unrelated titles don't match
800 return " ".join(base_name.split()).casefold() == " ".join(compare_name.split()).casefold()
801
802
803def _normalize_album_name(name: str) -> str:
804 """Return a punctuation/diacritic/whitespace-normalized album title for identity checks."""
805 return " ".join(create_safe_string(name).split())
806
807
808def _track_positions(tracks: Sequence[Track]) -> dict[tuple[int, int], Track]:
809 """Return tracks keyed by their (disc_number, track_number) position."""
810 if len({bool(track.disc_number) for track in tracks}) > 1:
811 # some tracks report a disc number and others don't: the shape can't be trusted
812 return {}
813 positions: dict[tuple[int, int], Track] = {}
814 for track in tracks:
815 if not track.track_number:
816 return {}
817 key = (track.disc_number or 1, track.track_number)
818 if key in positions:
819 # duplicate position: the tracklist shape cannot be trusted
820 return {}
821 positions[key] = track
822 return positions
823
824
825def _has_unknown_disc_layout(tracks: Sequence[Track]) -> bool:
826 """Return True if a tracklist reports no disc number at all (an assumed single disc)."""
827 return all(not track.disc_number for track in tracks)
828
829
830def _compare_track_fingerprint(base_track: Track, compare_track: Track) -> AlbumMatchEvidence:
831 """Return match evidence for a single album-track position."""
832 base_isrcs = _track_isrcs(base_track)
833 compare_isrcs = _track_isrcs(compare_track)
834 if base_isrcs and compare_isrcs:
835 if base_isrcs.isdisjoint(compare_isrcs):
836 # both sides tagged an ISRC and they disagree: a different recording/remaster
837 return AlbumMatchEvidence.NO_MATCH
838 if not base_track.duration or not compare_track.duration:
839 return AlbumMatchEvidence.INSUFFICIENT
840 if _duration_close(base_track.duration, compare_track.duration, _ISRC_DURATION_TOLERANCE):
841 return AlbumMatchEvidence.MATCH
842 return AlbumMatchEvidence.INSUFFICIENT
843
844 # no usable ISRC on (at least) one side: fall back to title/version + duration
845 if not base_track.name or not compare_track.name:
846 return AlbumMatchEvidence.INSUFFICIENT
847 if not compare_strings(base_track.name, compare_track.name, strict=True):
848 return AlbumMatchEvidence.NO_MATCH
849 if not compare_version(base_track.version, compare_track.version):
850 return AlbumMatchEvidence.NO_MATCH
851 if not base_track.duration or not compare_track.duration:
852 return AlbumMatchEvidence.INSUFFICIENT
853 if _duration_close(base_track.duration, compare_track.duration, _FALLBACK_DURATION_TOLERANCE):
854 return AlbumMatchEvidence.MATCH
855 return AlbumMatchEvidence.NO_MATCH
856
857
858def _track_isrcs(track: Track) -> set[str]:
859 """Return the structurally valid, normalized ISRCs tagged on a track."""
860 return {
861 normalize_external_id(ExternalID.ISRC, value)
862 for current_type, value in track.external_ids
863 if current_type == ExternalID.ISRC and is_valid_isrc(value)
864 }
865
866
867def _duration_close(base_duration: int, compare_duration: int, tolerance: int) -> bool:
868 """Return True if two track durations (in seconds) are within tolerance."""
869 return abs(base_duration - compare_duration) <= tolerance
870
871
872def _finalize_album_evidence(
873 ambiguous: bool,
874 base_tracks: Sequence[Track] | None,
875 compare_tracks: Sequence[Track] | None,
876) -> AlbumMatchEvidence:
877 """Combine an album's metadata ambiguity with an optional track fingerprint override."""
878 fingerprint_evidence = compare_album_track_fingerprint(base_tracks, compare_tracks)
879 if fingerprint_evidence == AlbumMatchEvidence.NO_MATCH:
880 # a conflicting tracklist is decisive even if the album's own metadata looked fine
881 return AlbumMatchEvidence.NO_MATCH
882 if not ambiguous:
883 return AlbumMatchEvidence.MATCH
884 return fingerprint_evidence
885