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