/
/
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 compare_track(
278 base_item: Track,
279 compare_item: Track,
280 strict: bool = True,
281 track_albums: list[Album] | None = None,
282) -> bool:
283 """Compare two track items and return True if they match."""
284 # return early on exact item_id match
285 if compare_item_ids(base_item, compare_item):
286 return True
287 # tracks on the same album but different discs are always distinct,
288 # even if they share external IDs (e.g. same recording on multiple discs)
289 if (
290 base_item.album
291 and compare_item.album
292 and base_item.disc_number
293 and compare_item.disc_number
294 and base_item.disc_number != compare_item.disc_number
295 and compare_album(base_item.album, compare_item.album, False)
296 ):
297 return False
298 # return early on (un)matched primary/unique external id
299 for ext_id in (
300 ExternalID.MB_RECORDING,
301 ExternalID.MB_TRACK,
302 ExternalID.ACOUSTID,
303 ):
304 external_id_match = compare_external_ids(
305 base_item.external_ids, compare_item.external_ids, ext_id
306 )
307 if external_id_match is not None:
308 return external_id_match
309 # check secondary external id matches
310 for ext_id in (
311 ExternalID.DISCOGS,
312 ExternalID.TADB,
313 ExternalID.ISRC,
314 ExternalID.ASIN,
315 ):
316 external_id_match = compare_external_ids(
317 base_item.external_ids, compare_item.external_ids, ext_id
318 )
319 if external_id_match is True:
320 # we got a 'soft-match' on a secondary external id (like ISRC)
321 # but we do a double check on duration
322 if abs(base_item.duration - compare_item.duration) <= 8:
323 return True
324
325 # compare name
326 if not compare_strings(base_item.name, compare_item.name, strict=True):
327 return False
328 # track artist(s) must match
329 if not compare_artists(base_item.artists, compare_item.artists, any_match=not strict):
330 return False
331 # track version must match
332 if strict and not compare_version(base_item.version, compare_item.version):
333 return False
334 # check if both tracks are (not) explicit
335 if base_item.metadata.explicit is None and isinstance(base_item.album, Album):
336 base_item.metadata.explicit = base_item.album.metadata.explicit
337 if compare_item.metadata.explicit is None and isinstance(compare_item.album, Album):
338 compare_item.metadata.explicit = compare_item.album.metadata.explicit
339 if strict and compare_explicit(base_item.metadata, compare_item.metadata) is False:
340 return False
341
342 # exact albumtrack match = 100% match
343 if (
344 base_item.album
345 and compare_item.album
346 and compare_album(base_item.album, compare_item.album, False)
347 and base_item.disc_number
348 and compare_item.disc_number
349 and base_item.track_number
350 and compare_item.track_number
351 and base_item.disc_number == compare_item.disc_number
352 and base_item.track_number == compare_item.track_number
353 ):
354 return True
355
356 # fallback: exact album match and (near-exact) track duration match
357 if (
358 base_item.album is not None
359 and compare_item.album is not None
360 and (base_item.track_number == 0 or compare_item.track_number == 0)
361 and compare_album(base_item.album, compare_item.album, False)
362 and abs(base_item.duration - compare_item.duration) <= 3
363 ):
364 return True
365
366 # fallback: additional compare albums provided for base track
367 if (
368 compare_item.album is not None
369 and track_albums
370 and abs(base_item.duration - compare_item.duration) <= 3
371 ):
372 for track_album in track_albums:
373 if compare_album(track_album, compare_item.album, False):
374 return True
375
376 # fallback edge case: albumless track with same duration
377 if (
378 base_item.album is None
379 and compare_item.album is None
380 and base_item.disc_number == 0
381 and compare_item.disc_number == 0
382 and base_item.track_number == 0
383 and compare_item.track_number == 0
384 and base_item.duration == compare_item.duration
385 ):
386 return True
387
388 if strict:
389 # in strict mode, we require an exact album match so return False here
390 return False
391
392 # Accept last resort (in non strict mode): (near) exact duration,
393 # otherwise fail all other cases.
394 # Note that as this stage, all other info already matches,
395 # such as title, artist etc.
396 return abs(base_item.duration - compare_item.duration) <= 2
397
398
399def compare_playlist(
400 base_item: Playlist | ItemMapping,
401 compare_item: Playlist | ItemMapping,
402 strict: bool = True,
403) -> bool | None:
404 """Compare two Playlist items and return True if they match."""
405 # require (exact) name match
406 if not compare_strings(base_item.name, compare_item.name, strict=strict):
407 return False
408 # require exact owner match (if not ItemMapping)
409 if isinstance(base_item, Playlist) and isinstance(compare_item, Playlist):
410 if not compare_strings(base_item.owner, compare_item.owner):
411 return False
412 # a playlist is always unique - so do a strict compare on item id(s)
413 return compare_item_ids(base_item, compare_item)
414
415
416def compare_radio(
417 base_item: Radio | ItemMapping,
418 compare_item: Radio | ItemMapping,
419 strict: bool = True,
420) -> bool | None:
421 """Compare two Radio items and return True if they match."""
422 # return early on exact item_id match
423 if compare_item_ids(base_item, compare_item):
424 return True
425 # a dynamic station is its provider's own, so a same-named station is a different one
426 if _is_dynamic_radio(base_item) or _is_dynamic_radio(compare_item):
427 return False
428 # compare version
429 if not compare_version(base_item.version, compare_item.version):
430 return False
431 # finally comparing on (exact) name match
432 return compare_strings(base_item.name, compare_item.name, strict=strict)
433
434
435def compare_audiobook(
436 base_item: Audiobook | ItemMapping,
437 compare_item: Audiobook | ItemMapping,
438 strict: bool = True,
439) -> bool | None:
440 """Compare two Audiobook 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
445 # return early on (un)matched external id
446 for ext_id in (
447 ExternalID.ASIN,
448 ExternalID.BARCODE,
449 ):
450 external_id_match = compare_external_ids(
451 base_item.external_ids, compare_item.external_ids, ext_id
452 )
453 if external_id_match is not None:
454 return external_id_match
455
456 # compare version
457 if not compare_version(base_item.version, compare_item.version):
458 return False
459 # compare name
460 if not compare_strings(base_item.name, compare_item.name, strict=True):
461 return False
462 if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
463 return True
464 # for strict matching we REQUIRE both items to be a real Audiobook object
465 assert isinstance(base_item, Audiobook)
466 assert isinstance(compare_item, Audiobook)
467 # compare publisher
468 if (
469 base_item.publisher
470 and compare_item.publisher
471 and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
472 ):
473 return False
474
475 def _audiobook_artist_name(value: str | Artist | ItemMapping) -> str:
476 return value.name if isinstance(value, Artist | ItemMapping) else value
477
478 # compare narrator(s) — different narrators indicate different recordings and must not be merged
479 if base_item.narrators and compare_item.narrators:
480 base_narrators = {
481 create_safe_string(_audiobook_artist_name(n)) for n in base_item.narrators
482 }
483 compare_narrators = {
484 create_safe_string(_audiobook_artist_name(n)) for n in compare_item.narrators
485 }
486 if base_narrators.isdisjoint(compare_narrators):
487 return False
488 # compare author(s)
489 for author in base_item.authors:
490 author_safe = create_safe_string(_audiobook_artist_name(author))
491 if author_safe in [
492 create_safe_string(_audiobook_artist_name(x)) for x in compare_item.authors
493 ]:
494 return True
495 return False
496
497
498def compare_podcast(
499 base_item: Podcast | ItemMapping,
500 compare_item: Podcast | ItemMapping,
501 strict: bool = True,
502) -> bool | None:
503 """Compare two Podcast items and return True if they match."""
504 # return early on exact item_id match
505 if compare_item_ids(base_item, compare_item):
506 return True
507
508 # return early on (un)matched external id
509 for ext_id in (
510 ExternalID.ASIN,
511 ExternalID.BARCODE,
512 ):
513 external_id_match = compare_external_ids(
514 base_item.external_ids, compare_item.external_ids, ext_id
515 )
516 if external_id_match is not None:
517 return external_id_match
518
519 # compare version
520 if not compare_version(base_item.version, compare_item.version):
521 return False
522 # compare name
523 if not compare_strings(base_item.name, compare_item.name, strict=True):
524 return False
525 if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)):
526 return True
527 # for strict matching we REQUIRE both items to be a real Podcast object
528 assert isinstance(base_item, Podcast)
529 assert isinstance(compare_item, Podcast)
530 # compare publisher
531 return not (
532 base_item.publisher
533 and compare_item.publisher
534 and not compare_strings(base_item.publisher, compare_item.publisher, strict=True)
535 )
536
537
538def compare_item_mapping(
539 base_item: ItemMapping,
540 compare_item: ItemMapping,
541 strict: bool = True,
542) -> bool | None:
543 """Compare two ItemMapping items and return True if they match."""
544 # return early on exact item_id match
545 if compare_item_ids(base_item, compare_item):
546 return True
547 # return early on (un)matched external id
548 # check all ExternalID, as ItemMapping is a minimized obj for all MediaItems
549 for ext_id in ExternalID:
550 external_id_match = compare_external_ids(
551 base_item.external_ids, compare_item.external_ids, ext_id
552 )
553 if external_id_match is not None:
554 return external_id_match
555 # compare version
556 if not compare_version(base_item.version, compare_item.version):
557 return False
558 # finally comparing on (exact) name match
559 return compare_strings(base_item.name, compare_item.name, strict=strict)
560
561
562def compare_artists(
563 base_items: list[Artist | ItemMapping],
564 compare_items: list[Artist | ItemMapping],
565 any_match: bool = True,
566) -> bool:
567 """Compare two lists of artist and return True if both lists match (exactly)."""
568 if not base_items or not compare_items:
569 return False
570 # match if first artist matches in both lists
571 if compare_artist(base_items[0], compare_items[0]):
572 return True
573 # compare the artist lists
574 matches = 0
575 for base_item in base_items:
576 for compare_item in compare_items:
577 if compare_artist(base_item, compare_item):
578 if any_match:
579 return True
580 matches += 1
581 return len(base_items) == len(compare_items) == matches
582
583
584def compare_albums(
585 base_items: list[Album | ItemMapping],
586 compare_items: list[Album | ItemMapping],
587 any_match: bool = True,
588) -> bool:
589 """Compare two lists of albums and return True if a match was found."""
590 matches = 0
591 for base_item in base_items:
592 for compare_item in compare_items:
593 if compare_album(base_item, compare_item):
594 if any_match:
595 return True
596 matches += 1
597 return len(base_items) == matches
598
599
600def compare_item_ids(
601 base_item: MediaItem | ItemMapping, compare_item: MediaItem | ItemMapping
602) -> bool:
603 """Compare item_id(s) of two media items."""
604 if not base_item.provider or not compare_item.provider:
605 return False
606 if not base_item.item_id or not compare_item.item_id:
607 return False
608 if base_item.provider == compare_item.provider and base_item.item_id == compare_item.item_id:
609 return True
610
611 base_prov_ids = getattr(base_item, "provider_mappings", None)
612 compare_prov_ids = getattr(compare_item, "provider_mappings", None)
613
614 if base_prov_ids is not None:
615 assert isinstance(base_item, MediaItem) # for type checking
616 for prov_l in base_item.provider_mappings:
617 if (
618 prov_l.provider_instance == compare_item.provider
619 and prov_l.item_id == compare_item.item_id
620 ):
621 return True
622
623 if compare_prov_ids is not None:
624 assert isinstance(compare_item, MediaItem) # for type checking
625 for prov_r in compare_item.provider_mappings:
626 if (
627 prov_r.provider_instance == base_item.provider
628 and prov_r.item_id == base_item.item_id
629 ):
630 return True
631
632 if base_prov_ids is not None and compare_prov_ids is not None:
633 assert isinstance(base_item, MediaItem) # for type checking
634 assert isinstance(compare_item, MediaItem) # for type checking
635 for prov_l in base_item.provider_mappings:
636 for prov_r in compare_item.provider_mappings:
637 if prov_l.provider_domain != prov_r.provider_domain:
638 continue
639 if (
640 prov_l.is_unique or prov_r.is_unique
641 ) and prov_l.provider_instance != prov_r.provider_instance:
642 continue
643 if prov_l.item_id == prov_r.item_id:
644 return True
645 return False
646
647
648def compare_external_ids(
649 external_ids_base: set[tuple[ExternalID, str]],
650 external_ids_compare: set[tuple[ExternalID, str]],
651 external_id_type: ExternalID,
652) -> bool | None:
653 """Compare external ids and return True if a match was found."""
654 base_ids = {
655 normalize_external_id(external_id_type, value)
656 for current_type, value in external_ids_base
657 if current_type == external_id_type
658 }
659 if not base_ids:
660 # return early if the requested external id type is not present in the base set
661 return None
662 compare_ids = {
663 normalize_external_id(external_id_type, value)
664 for current_type, value in external_ids_compare
665 if current_type == external_id_type
666 }
667 if not compare_ids:
668 # return early if the requested external id type is not present in the compare set
669 return None
670 if base_ids.intersection(compare_ids):
671 return True
672 if external_id_type.is_unique:
673 return False
674 return None
675
676
677def loose_compare_strings(base: str, alt: str) -> bool:
678 """Compare strings and return True even on partial match."""
679 # this is used to display 'versions' of the same track/album
680 # where we account for other spelling or some additional wording in the title
681 if len(base) <= 3 or len(alt) <= 3:
682 return compare_strings(base, alt, True)
683 word_count = len(base.strip().split(" "))
684 if word_count == 1 and len(base) < 10:
685 return compare_strings(base, alt, False)
686 base_comp = create_safe_string(base)
687 alt_comp = create_safe_string(alt)
688 if base_comp in alt_comp:
689 return True
690 return base_comp in alt_comp
691
692
693def compare_strings(str1: str, str2: str, strict: bool = True) -> bool:
694 """Compare strings and return True if we have an (almost) perfect match."""
695 if not str1 or not str2:
696 return False
697 str1_lower = str1.lower()
698 str2_lower = str2.lower()
699 if strict:
700 return str1_lower == str2_lower
701 # return early if total length mismatch
702 if abs(len(str1) - len(str2)) > 4:
703 return False
704 # handle '&' vs 'And'
705 if " & " in str1_lower and " and " in str2_lower:
706 str2 = str2_lower.replace(" and ", " & ")
707 elif " and " in str1_lower and " & " in str2:
708 str2 = str2_lower.replace(" & ", " and ")
709 if create_safe_string(str1) == create_safe_string(str2):
710 return True
711 # last resort: use difflib to compare strings
712 required_accuracy = 0.9 if (len(str1) + len(str2)) > 18 else 0.8
713 return SequenceMatcher(a=str1_lower, b=str2_lower).ratio() > required_accuracy
714
715
716def compare_version(base_version: str, compare_version: str) -> bool:
717 """Compare version string."""
718 return _normalize_version_tokens(base_version) == _normalize_version_tokens(compare_version)
719
720
721def compare_explicit(base: MediaItemMetadata, compare: MediaItemMetadata) -> bool | None:
722 """Compare if explicit is same in metadata."""
723 if base.explicit is not None and compare.explicit is not None:
724 # explicitness info is not always present in metadata
725 # only strict compare them if both have the info set
726 return base.explicit == compare.explicit
727 return None
728
729
730@lru_cache(maxsize=1024)
731def _normalize_version_tokens(value: str) -> tuple[str, ...]:
732 """Return meaningful version tokens in stable order."""
733 if not value or create_safe_string(value) in _IGNORE_VERSION_KEYS:
734 return ()
735 tokens = (
736 _VERSION_WORD_ALIASES.get(token, token)
737 for token in re.findall(r"[^\W_]+", value.casefold())
738 )
739 return tuple(sorted(token for token in tokens if token not in _VERSION_IGNORE_WORDS))
740
741
742def _is_dynamic_radio(item: Radio | ItemMapping) -> bool:
743 """Return True if the item is a dynamic radio station."""
744 return isinstance(item, Radio) and item.is_dynamic
745
746
747def _compare_album_version(base_version: str, compare_version: str) -> AlbumMatchEvidence:
748 """Return match evidence for an album version/edition comparison."""
749 base_tokens = set(_normalize_version_tokens(base_version))
750 compare_tokens = set(_normalize_version_tokens(compare_version))
751 if base_tokens == compare_tokens:
752 return AlbumMatchEvidence.MATCH
753 # a recording-changing qualifier (live, karaoke, remix, ...) makes an otherwise
754 # unequal pair of editions unsafe to merge, wherever it appears in either wording,
755 # not only when it is the token that happens to differ between the two, and even
756 # when the other side omits version metadata entirely
757 if (base_tokens | compare_tokens) & _RECORDING_CONFLICT_VERSION_TOKENS:
758 return AlbumMatchEvidence.NO_MATCH
759 if not base_tokens or not compare_tokens:
760 # a provider commonly omits edition metadata entirely (e.g. a remaster tagged
761 # without a version string), so a blank version next to a real one is
762 # undecided rather than a proven conflict: let a tracklist resolve it
763 return AlbumMatchEvidence.INSUFFICIENT
764 if base_tokens < compare_tokens or compare_tokens < base_tokens:
765 # one version's wording is a strict subset of the other's (e.g. "2022 Remaster"
766 # vs. "Deluxe 2022 Remaster"): an ambiguous packaging difference a tracklist can resolve
767 return AlbumMatchEvidence.INSUFFICIENT
768 return AlbumMatchEvidence.NO_MATCH
769
770
771def _compare_album_name(base_name: str, compare_name: str) -> bool:
772 """Return True if two album titles are the same identity, ignoring formatting drift."""
773 base_safe = _normalize_album_name(base_name)
774 compare_safe = _normalize_album_name(compare_name)
775 if base_safe and compare_safe:
776 return base_safe == compare_safe
777 if base_safe or compare_safe:
778 return False
779 # both titles collapse to nothing under normalization (e.g. pure punctuation):
780 # fall back to a whitespace-normalized raw comparison so unrelated titles don't match
781 return " ".join(base_name.split()).casefold() == " ".join(compare_name.split()).casefold()
782
783
784def _normalize_album_name(name: str) -> str:
785 """Return a punctuation/diacritic/whitespace-normalized album title for identity checks."""
786 return " ".join(create_safe_string(name).split())
787
788
789def _track_positions(tracks: Sequence[Track]) -> dict[tuple[int, int], Track]:
790 """Return tracks keyed by their (disc_number, track_number) position."""
791 if len({bool(track.disc_number) for track in tracks}) > 1:
792 # some tracks report a disc number and others don't: the shape can't be trusted
793 return {}
794 positions: dict[tuple[int, int], Track] = {}
795 for track in tracks:
796 if not track.track_number:
797 return {}
798 key = (track.disc_number or 1, track.track_number)
799 if key in positions:
800 # duplicate position: the tracklist shape cannot be trusted
801 return {}
802 positions[key] = track
803 return positions
804
805
806def _has_unknown_disc_layout(tracks: Sequence[Track]) -> bool:
807 """Return True if a tracklist reports no disc number at all (an assumed single disc)."""
808 return all(not track.disc_number for track in tracks)
809
810
811def _compare_track_fingerprint(base_track: Track, compare_track: Track) -> AlbumMatchEvidence:
812 """Return match evidence for a single album-track position."""
813 base_isrcs = _track_isrcs(base_track)
814 compare_isrcs = _track_isrcs(compare_track)
815 if base_isrcs and compare_isrcs:
816 if base_isrcs.isdisjoint(compare_isrcs):
817 # both sides tagged an ISRC and they disagree: a different recording/remaster
818 return AlbumMatchEvidence.NO_MATCH
819 if not base_track.duration or not compare_track.duration:
820 return AlbumMatchEvidence.INSUFFICIENT
821 if _duration_close(base_track.duration, compare_track.duration, _ISRC_DURATION_TOLERANCE):
822 return AlbumMatchEvidence.MATCH
823 return AlbumMatchEvidence.INSUFFICIENT
824
825 # no usable ISRC on (at least) one side: fall back to title/version + duration
826 if not base_track.name or not compare_track.name:
827 return AlbumMatchEvidence.INSUFFICIENT
828 if not compare_strings(base_track.name, compare_track.name, strict=True):
829 return AlbumMatchEvidence.NO_MATCH
830 if not compare_version(base_track.version, compare_track.version):
831 return AlbumMatchEvidence.NO_MATCH
832 if not base_track.duration or not compare_track.duration:
833 return AlbumMatchEvidence.INSUFFICIENT
834 if _duration_close(base_track.duration, compare_track.duration, _FALLBACK_DURATION_TOLERANCE):
835 return AlbumMatchEvidence.MATCH
836 return AlbumMatchEvidence.NO_MATCH
837
838
839def _track_isrcs(track: Track) -> set[str]:
840 """Return the structurally valid, normalized ISRCs tagged on a track."""
841 return {
842 normalize_external_id(ExternalID.ISRC, value)
843 for current_type, value in track.external_ids
844 if current_type == ExternalID.ISRC and is_valid_isrc(value)
845 }
846
847
848def _duration_close(base_duration: int, compare_duration: int, tolerance: int) -> bool:
849 """Return True if two track durations (in seconds) are within tolerance."""
850 return abs(base_duration - compare_duration) <= tolerance
851
852
853def _finalize_album_evidence(
854 ambiguous: bool,
855 base_tracks: Sequence[Track] | None,
856 compare_tracks: Sequence[Track] | None,
857) -> AlbumMatchEvidence:
858 """Combine an album's metadata ambiguity with an optional track fingerprint override."""
859 fingerprint_evidence = compare_album_track_fingerprint(base_tracks, compare_tracks)
860 if fingerprint_evidence == AlbumMatchEvidence.NO_MATCH:
861 # a conflicting tracklist is decisive even if the album's own metadata looked fine
862 return AlbumMatchEvidence.NO_MATCH
863 if not ambiguous:
864 return AlbumMatchEvidence.MATCH
865 return fingerprint_evidence
866