/
/
1"""Model/base for a Music Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from collections.abc import Sequence
8from contextlib import asynccontextmanager
9from contextvars import ContextVar
10from dataclasses import dataclass, field
11from datetime import datetime
12from typing import TYPE_CHECKING, Final, cast
13
14from music_assistant_models.background_task import TaskSchedule
15from music_assistant_models.enums import ArtistType, MediaType, ProviderFeature
16from music_assistant_models.errors import (
17 AudioError,
18 InvalidDataError,
19 MediaNotFoundError,
20 MusicAssistantError,
21 UnsupportedFeaturedException,
22)
23from music_assistant_models.media_items import (
24 Album,
25 Artist,
26 Audiobook,
27 BrowseFolder,
28 ItemMapping,
29 MediaItemType,
30 Playlist,
31 Podcast,
32 PodcastEpisode,
33 Radio,
34 RecommendationFolder,
35 SearchResults,
36 SoundEffect,
37 Track,
38 UniqueList,
39)
40
41from music_assistant.constants import (
42 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
43 CONF_ENTRY_LIBRARY_SYNC_DELETIONS,
44 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS,
45 PlaylistPlayableItem,
46)
47from music_assistant.controllers.tasks.context import (
48 report_current_task_failure,
49 update_current_task_progress_text,
50)
51
52from .provider import Provider
53
54if TYPE_CHECKING:
55 from collections.abc import AsyncGenerator
56
57 from music_assistant_models.config_entries import ProviderConfig
58 from music_assistant_models.provider import ProviderManifest
59 from music_assistant_models.streamdetails import StreamDetails
60
61 from music_assistant.controllers.music.media.base import (
62 AudiobookSyncDetails,
63 LibraryItemSyncDetails,
64 TrackSyncDetails,
65 )
66 from music_assistant.mass import MusicAssistant
67
68CACHE_CATEGORY_PREV_LIBRARY_IDS: Final[int] = 1
69DEFAULT_MAX_CONCURRENT_STREAMS: Final[int] = 5
70# a provider-wide payload change fails every single item, so only the first failures
71# of a sync run are logged in full to keep the (rotating) log file usable
72MAX_LOGGED_SYNC_FAILURES: Final[int] = 25
73MAX_SYNC_ERROR_DETAIL: Final[int] = 200
74# skipped id's are resolved back to library id's in batches of this size
75SKIPPED_ITEM_QUERY_LIMIT: Final[int] = 500
76
77LIBRARY_FEATURE_BY_MEDIA_TYPE: Final[dict[MediaType, ProviderFeature]] = {
78 MediaType.ARTIST: ProviderFeature.LIBRARY_ARTISTS,
79 MediaType.ALBUM: ProviderFeature.LIBRARY_ALBUMS,
80 MediaType.TRACK: ProviderFeature.LIBRARY_TRACKS,
81 MediaType.PLAYLIST: ProviderFeature.LIBRARY_PLAYLISTS,
82 MediaType.RADIO: ProviderFeature.LIBRARY_RADIOS,
83 MediaType.AUDIOBOOK: ProviderFeature.LIBRARY_AUDIOBOOKS,
84 MediaType.PODCAST: ProviderFeature.LIBRARY_PODCASTS,
85}
86
87
88@dataclass
89class SyncRunState:
90 """
91 Failure state of one library sync run.
92
93 :param incomplete_media_types: Media types the run failed to collect an item for, which
94 makes their result set an unsafe basis for deleting anything from the library.
95 :param failures: Number of item failures reported by the run so far.
96 :param skipped_item_ids: Provider item id's the provider dropped while listing its
97 library, per media type.
98 """
99
100 incomplete_media_types: set[MediaType] = field(default_factory=set)
101 failures: int = 0
102 skipped_item_ids: dict[MediaType, set[str]] = field(default_factory=dict)
103
104
105# scoped per run rather than per provider: a standalone import_album_tracks() is
106# launched as its own task, so it must not consume or inflate a running sync's state
107SYNC_RUN_STATE: Final[ContextVar[SyncRunState | None]] = ContextVar(
108 "music_provider_sync_run", default=None
109)
110
111
112def sync_run_state() -> SyncRunState:
113 """Return the state of the sync run in progress, starting one if there is none."""
114 if (state := SYNC_RUN_STATE.get()) is None:
115 state = SyncRunState()
116 SYNC_RUN_STATE.set(state)
117 return state
118
119
120class ProviderStreamLimitError(AudioError):
121 """Raised when a music provider has no source-stream slot available."""
122
123 translation_key = "provider_stream_limit"
124
125 def __init__(self, provider: MusicProvider, wait_timeout: float | None) -> None:
126 """
127 Initialize the provider stream limit error.
128
129 :param provider: Provider instance whose source-stream limit was reached.
130 :param wait_timeout: Seconds spent waiting for a slot, or None for an unbounded wait.
131 """
132 limit = provider.max_concurrent_streams
133 assert limit is not None
134 wait_text = f" after waiting {wait_timeout:g} seconds" if wait_timeout is not None else ""
135 super().__init__(
136 f"{provider.name} has reached its limit of {limit} "
137 f"concurrent source streams{wait_text}.",
138 translation_args=[provider.name, limit],
139 )
140 self.provider_instance = provider.instance_id
141 self.limit = limit
142
143
144def describe_sync_error(err: Exception) -> str:
145 """Return a short description of a sync failure, safe to log and to report to clients."""
146 if isinstance(err, MusicAssistantError):
147 return str(err)
148 # an unexpected error can carry an entire api response as its message, which would end
149 # up in the log and - through the task failure list - in every connected client. report
150 # it by type with a clipped detail and leave the full payload to the debug traceback
151 detail = str(err)
152 if not detail:
153 return type(err).__name__
154 if len(detail) > MAX_SYNC_ERROR_DETAIL:
155 detail = f"{detail[:MAX_SYNC_ERROR_DETAIL]}..."
156 return f"{type(err).__name__}: {detail}"
157
158
159class MusicProvider(Provider):
160 """
161 Base representation of a Music Provider (controller).
162
163 Music Provider implementations should inherit from this base model.
164 """
165
166 def __init__(
167 self,
168 mass: MusicAssistant,
169 manifest: ProviderManifest,
170 config: ProviderConfig,
171 supported_features: set[ProviderFeature] | None = None,
172 ) -> None:
173 """Initialize MusicProvider."""
174 super().__init__(mass, manifest, config, supported_features)
175 max_concurrent_streams = self.max_concurrent_streams
176 if max_concurrent_streams is not None and max_concurrent_streams < 1:
177 raise ValueError("max_concurrent_streams must be at least 1 or None")
178 self._stream_semaphore = (
179 asyncio.BoundedSemaphore(max_concurrent_streams)
180 if max_concurrent_streams is not None
181 else None
182 )
183
184 @property
185 def delivers_normalized_audio(self) -> bool:
186 """
187 Return whether this provider hands over audio it has already normalized.
188
189 True means the source applies a loudness target of its own, so Music
190 Assistant leaves the level alone instead of measuring and correcting it
191 a second time. Only say so when the audio really is normalized on the
192 way out: nothing downstream double-checks it.
193 """
194 return False
195
196 @property
197 def max_concurrent_streams(self) -> int | None:
198 """
199 Return the number of source streams Music Assistant may run against this provider.
200
201 None means no limit is imposed, which is the correct answer for local and
202 self-hosted sources. Streaming providers get a conservative default of five;
203 override with a lower, evidence-backed value where the service enforces one.
204 Plugin providers (exclusive audio sources) manage their own session exclusivity
205 and are not covered by this limit.
206 """
207 return DEFAULT_MAX_CONCURRENT_STREAMS if self.is_streaming_provider else None
208
209 @property
210 def has_available_stream_slot(self) -> bool:
211 """Return whether a source stream can start without waiting."""
212 return self._stream_semaphore is None or not self._stream_semaphore.locked()
213
214 @asynccontextmanager
215 async def acquire_stream_slot(self, wait_timeout: float | None) -> AsyncGenerator[None]:
216 """
217 Acquire one source-stream slot for the duration of the context.
218
219 :param wait_timeout: Maximum seconds to wait, or None to wait without a timeout.
220 :raises ProviderStreamLimitError: If no slot becomes available before the timeout.
221 """
222 semaphore = self._stream_semaphore
223 if semaphore is None:
224 yield
225 return
226 try:
227 if wait_timeout is None:
228 await semaphore.acquire()
229 else:
230 async with asyncio.timeout(wait_timeout):
231 await semaphore.acquire()
232 except TimeoutError as err:
233 raise ProviderStreamLimitError(self, wait_timeout) from err
234 try:
235 yield
236 finally:
237 semaphore.release()
238
239 @property
240 def is_streaming_provider(self) -> bool:
241 """
242 Return True if the provider is a streaming provider.
243
244 This literally means that the catalog is not the same as the library contents.
245 For local based providers (files, plex), the catalog is the same as the library content.
246 It also means that data is if this provider is NOT a streaming provider,
247 data cross instances is unique, the catalog and library differs per instance.
248
249 Setting this to True will only query one instance of the provider for search and lookups.
250 Setting this to False will query all instances of this provider for search and lookups.
251 """
252 return True
253
254 @property
255 def supported_media_types(self) -> set[MediaType]:
256 """
257 Return the media types this provider can serve.
258
259 Defaults to the media types the provider declares library support for.
260 Override for providers that can serve (search/stream) media types they
261 cannot list as library items, so they are eligible for search-based
262 lookups such as cross-provider matching and versions.
263 """
264 return {
265 media_type
266 for media_type, feature in LIBRARY_FEATURE_BY_MEDIA_TYPE.items()
267 if feature in self.supported_features
268 }
269
270 @property
271 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
272 """
273 Return the errors a library sync must never treat as a skippable item failure.
274
275 Declare the errors this provider raises to signal something a wrapper around its
276 own methods has to act on, such as an expired token that triggers a reauthenticate
277 and a retry. Anything listed here is re-raised instead of skipping the item.
278 """
279 return ()
280
281 @property
282 def supported_artist_types(self) -> set[ArtistType]:
283 """
284 Return all supported artist types by this provider.
285
286 Note, that this property currently is only used, to verify support of artists with
287 ArtistType.AUTHOR or ArtistType.NARRATOR.
288 """
289 return {ArtistType.SINGER}
290
291 async def loaded_in_mass(self) -> None:
292 """Call after the provider has been loaded."""
293
294 async def search(
295 self,
296 search_query: str,
297 media_types: list[MediaType],
298 limit: int = 5,
299 ) -> SearchResults:
300 """
301 Perform search on musicprovider.
302
303 :param search_query: Search query.
304 :param media_types: A list of media_types to include.
305 :param limit: Number of items to return in the search (per type).
306 """
307 if ProviderFeature.SEARCH in self.supported_features:
308 raise NotImplementedError
309 return SearchResults()
310
311 async def get_library_artists(self) -> AsyncGenerator[Artist]:
312 """Retrieve library artists from the provider."""
313 yield # type: ignore[misc]
314 raise NotImplementedError
315
316 async def get_library_albums(self) -> AsyncGenerator[Album]:
317 """Retrieve library albums from the provider."""
318 yield # type: ignore[misc]
319 raise NotImplementedError
320
321 async def get_library_tracks(self) -> AsyncGenerator[Track]:
322 """Retrieve library tracks from the provider."""
323 yield # type: ignore[misc]
324 raise NotImplementedError
325
326 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
327 """Retrieve library/subscribed playlists from the provider."""
328 yield # type: ignore[misc]
329 raise NotImplementedError
330
331 async def get_library_radios(self) -> AsyncGenerator[Radio]:
332 """Retrieve library/subscribed radio stations from the provider."""
333 yield # type: ignore[misc]
334 raise NotImplementedError
335
336 async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook]:
337 """Retrieve library/subscribed audiobooks from the provider."""
338 yield # type: ignore[misc]
339 raise NotImplementedError
340
341 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
342 """Retrieve library/subscribed podcasts from the provider."""
343 yield # type: ignore[misc]
344 raise NotImplementedError
345
346 async def get_library_genres(self) -> AsyncGenerator[str]:
347 """Retrieve library genres from the provider."""
348 yield # type: ignore[misc]
349 raise NotImplementedError
350
351 async def get_artist(self, prov_artist_id: str) -> Artist:
352 """Get full artist details by id."""
353 raise NotImplementedError
354
355 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
356 """
357 Get a list of all albums for the given artist.
358
359 Only called if provider supports ProviderFeature.ARTIST_ALBUMS.
360 """
361 raise NotImplementedError
362
363 async def get_artist_tracks(self, prov_artist_id: str) -> list[Track]:
364 """
365 Get a list of all tracks for the given artist.
366
367 Only called if provider supports ProviderFeature.ARTIST_TRACKS.
368 """
369 raise NotImplementedError
370
371 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
372 """
373 Get a list of most popular tracks for the given artist.
374
375 Only called if provider supports ProviderFeature.ARTIST_TOPTRACKS.
376 """
377 raise NotImplementedError
378
379 async def get_artist_topalbums(self, prov_artist_id: str) -> list[Album]:
380 """
381 Get a list of most popular albums for the given artist.
382
383 Only called if provider supports ProviderFeature.ARTIST_TOPALBUMS.
384 """
385 raise NotImplementedError
386
387 async def get_album(self, prov_album_id: str) -> Album:
388 """Get full album details by id."""
389 raise NotImplementedError
390
391 async def get_track(self, prov_track_id: str) -> Track:
392 """Get full track details by id."""
393 raise NotImplementedError
394
395 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
396 """Get full playlist details by id."""
397 raise NotImplementedError
398
399 async def get_radio(self, prov_radio_id: str) -> Radio:
400 """Get full radio details by id."""
401 raise NotImplementedError
402
403 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
404 """Get full audiobook details by id."""
405 raise NotImplementedError
406
407 async def get_author_audiobooks(self, prov_artist_id: str) -> list[Audiobook]:
408 """
409 Get a list of all audiobooks for the given author.
410
411 Only called if provider supports ProviderFeature.AUTHOR_AUDIOBOOKS.
412 """
413 raise NotImplementedError
414
415 async def get_narrator_audiobooks(self, prov_artist_id: str) -> list[Audiobook]:
416 """
417 Get a list of all audiobooks for the given narrator.
418
419 Only called if provider supports ProviderFeature.NARRATOR_AUDIOBOOKS.
420 """
421 raise NotImplementedError
422
423 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
424 """Get full podcast details by id."""
425 raise NotImplementedError
426
427 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
428 """Get (full) podcast episode details by id."""
429 raise NotImplementedError
430
431 async def get_sound_effect(self, prov_sound_effect_id: str) -> SoundEffect:
432 """Get full sound effect details by id."""
433 raise NotImplementedError
434
435 async def get_sound_effects(self) -> AsyncGenerator[SoundEffect]:
436 """
437 Get all sound effect items this provider offers.
438
439 Sound effects are not library-backed; they are fetched live from the provider.
440 Only called if provider supports ProviderFeature.SOUND_EFFECTS.
441 """
442 yield # type: ignore[misc]
443 raise NotImplementedError
444
445 async def get_item_genre_names(self, media_type: MediaType, item_id: str) -> set[str]:
446 """Return genre names for a single item."""
447 raise NotImplementedError
448
449 async def get_album_tracks(
450 self,
451 prov_album_id: str,
452 ) -> list[Track]:
453 """Get album tracks for given album id."""
454 raise NotImplementedError
455
456 async def get_playlist_tracks(
457 self,
458 prov_playlist_id: str,
459 page: int = 0,
460 ) -> Sequence[PlaylistPlayableItem]:
461 """Get all playlist tracks for given playlist id."""
462 raise NotImplementedError
463
464 async def get_dynamic_radio_tracks(self, prov_radio_id: str) -> list[Track]:
465 """
466 Return a fresh batch of tracks for a dynamic radio station.
467
468 Only called for a Radio with `is_dynamic` set. Every call returns a new batch;
469 there is no stable listing and no pagination.
470
471 :param prov_radio_id: The provider's ID of the radio station.
472 """
473 raise NotImplementedError
474
475 async def get_podcast_episodes(
476 self,
477 prov_podcast_id: str,
478 ) -> AsyncGenerator[PodcastEpisode]:
479 """Get all PodcastEpisodes for given podcast id."""
480 yield # type: ignore[misc]
481 raise NotImplementedError
482
483 async def library_add(self, item: MediaItemType) -> bool:
484 """Add item to provider's library. Return true on success."""
485 if (
486 item.media_type == MediaType.ARTIST
487 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
488 ):
489 raise NotImplementedError
490 if (
491 item.media_type == MediaType.ALBUM
492 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
493 ):
494 raise NotImplementedError
495 if (
496 item.media_type == MediaType.TRACK
497 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
498 ):
499 raise NotImplementedError
500 if (
501 item.media_type == MediaType.PLAYLIST
502 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
503 ):
504 raise NotImplementedError
505 if (
506 item.media_type == MediaType.RADIO
507 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
508 ):
509 raise NotImplementedError
510 if (
511 item.media_type == MediaType.AUDIOBOOK
512 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
513 ):
514 raise NotImplementedError
515 if (
516 item.media_type == MediaType.PODCAST
517 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
518 ):
519 raise NotImplementedError
520 self.logger.info(
521 "Provider %s does not support library edit, "
522 "the action will only be performed in the local database.",
523 self.name,
524 )
525 return True
526
527 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
528 """Remove item from provider's library. Return true on success."""
529 if (
530 media_type == MediaType.ARTIST
531 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
532 ):
533 raise NotImplementedError
534 if (
535 media_type == MediaType.ALBUM
536 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
537 ):
538 raise NotImplementedError
539 if (
540 media_type == MediaType.TRACK
541 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
542 ):
543 raise NotImplementedError
544 if (
545 media_type == MediaType.PLAYLIST
546 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
547 ):
548 raise NotImplementedError
549 if (
550 media_type == MediaType.RADIO
551 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
552 ):
553 raise NotImplementedError
554 if (
555 media_type == MediaType.AUDIOBOOK
556 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
557 ):
558 raise NotImplementedError
559 if (
560 media_type == MediaType.PODCAST
561 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
562 ):
563 raise NotImplementedError
564 self.logger.info(
565 "Provider %s does not support library edit, "
566 "the action will only be performed in the local database.",
567 self.name,
568 )
569 return True
570
571 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
572 """
573 Set favorite status for item in provider's library.
574
575 Only called if provider supports ProviderFeature.FAVORITE_*_EDIT.
576
577 Note that this should only be implemented by a provider implementation if
578 the provider differentiates between 'in library' and 'favorited' items.
579 """
580 if (
581 media_type == MediaType.ARTIST
582 and ProviderFeature.FAVORITE_ARTISTS_EDIT in self.supported_features
583 ):
584 raise NotImplementedError
585 if (
586 media_type == MediaType.ALBUM
587 and ProviderFeature.FAVORITE_ALBUMS_EDIT in self.supported_features
588 ):
589 raise NotImplementedError
590 if (
591 media_type == MediaType.TRACK
592 and ProviderFeature.FAVORITE_TRACKS_EDIT in self.supported_features
593 ):
594 raise NotImplementedError
595 if (
596 media_type == MediaType.PLAYLIST
597 and ProviderFeature.FAVORITE_PLAYLISTS_EDIT in self.supported_features
598 ):
599 raise NotImplementedError
600 if (
601 media_type == MediaType.RADIO
602 and ProviderFeature.FAVORITE_RADIOS_EDIT in self.supported_features
603 ):
604 raise NotImplementedError
605 if (
606 media_type == MediaType.AUDIOBOOK
607 and ProviderFeature.FAVORITE_AUDIOBOOKS_EDIT in self.supported_features
608 ):
609 raise NotImplementedError
610 if (
611 media_type == MediaType.PODCAST
612 and ProviderFeature.FAVORITE_PODCASTS_EDIT in self.supported_features
613 ):
614 raise NotImplementedError
615
616 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
617 """
618 Add track(s) to playlist.
619
620 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
621 """
622 raise NotImplementedError
623
624 async def remove_playlist_tracks(
625 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
626 ) -> None:
627 """
628 Remove track(s) from playlist.
629
630 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
631 """
632 raise NotImplementedError
633
634 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
635 """
636 Create a new playlist on provider with given name and targeting media_types.
637
638 Only called if provider supports ProviderFeature.PLAYLIST_CREATE.
639 """
640 raise NotImplementedError
641
642 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
643 """
644 Retrieve a dynamic list of similar tracks based on the provided track.
645
646 Only called if provider supports ProviderFeature.SIMILAR_TRACKS.
647 """
648 raise NotImplementedError
649
650 async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]:
651 """
652 Retrieve a dynamic list of similar artists based on the provided artist.
653
654 Only called if provider supports ProviderFeature.SIMILAR_ARTISTS.
655 """
656 raise NotImplementedError
657
658 async def get_resume_position(
659 self, item_id: str, media_type: MediaType
660 ) -> tuple[bool, int, datetime | None]:
661 """
662 Get progress (resume point) details for the given Audiobook or Podcast episode.
663
664 This is a separate call from the regular get_item call to ensure the resume position
665 is always up-to-date and because a lot providers have this info present on a dedicated
666 endpoint.
667
668 Will be called right before playback starts to ensure the resume position is correct.
669
670 Returns a boolean with the fully_played status
671 an integer with the resume position in ms,
672 and an optional timestamp as datetime giving when this resume position was set
673 """
674 raise NotImplementedError
675
676 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
677 """Get streamdetails for a track/radio/chapter/episode."""
678 raise NotImplementedError
679
680 async def get_audio_stream(
681 self, streamdetails: StreamDetails, seek_position: int = 0
682 ) -> AsyncGenerator[bytes]:
683 """
684 Return the (custom) audio stream for the provider item.
685
686 Will only be called when the stream_type is set to CUSTOM.
687 """
688 yield b""
689 raise NotImplementedError
690
691 async def on_streamed(
692 self,
693 streamdetails: StreamDetails,
694 ) -> None:
695 """
696 Handle callback when given streamdetails completed streaming.
697
698 To get the number of seconds streamed, see streamdetails.seconds_streamed.
699 To get the number of seconds seeked/skipped, see streamdetails.seek_position.
700 Note that seconds_streamed is the total streamed seconds, so without seeked time.
701
702 NOTE: Due to internal and player buffering,
703 this may be called in advance of the actual completion.
704 """
705
706 async def on_played(
707 self,
708 media_type: MediaType,
709 prov_item_id: str,
710 fully_played: bool,
711 position: int,
712 media_item: MediaItemType,
713 is_playing: bool = False,
714 ) -> None:
715 """
716 Handle callback when a (playable) media item has been played.
717
718 This is called by the Queue controller when;
719 - a track has been fully played
720 - a track has been stopped (or skipped) after being played
721 - every 30s when a track is playing
722
723 Fully played is True when the track has been played to the end.
724
725 Position is the last known position of the track in seconds, to sync resume state.
726 When fully_played is set to false and position is 0,
727 the user marked the item as unplayed in the UI.
728
729 media_item is the full media item details of the played/playing track.
730
731 is_playing is True when the track is currently playing.
732 """
733
734 async def on_item_updated(self, item: MediaItemType) -> None:
735 """
736 Handle callback when a library item's metadata has been updated.
737
738 Providers can implement this to sync changes to their own storage
739 (e.g. config entries, file tags).
740
741 :param item: The updated library item.
742 """
743
744 async def resolve_image(self, path: str) -> str | bytes:
745 """
746 Resolve an image from an image path.
747
748 This either returns (a generator to get) raw bytes of the image or
749 a string with an http(s) URL or local path that is accessible from the server.
750 """
751 return path
752
753 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: # noqa: PLR0911
754 """
755 Browse this provider's items.
756
757 :param path: The path to browse, (e.g. provider_id://artists).
758 """
759 if ProviderFeature.BROWSE not in self.supported_features:
760 # we may NOT use the default implementation if the provider does not support browse
761 raise NotImplementedError
762
763 path_parts = path.split("://")[1].split("/")
764 subpath = path_parts[0] if len(path_parts) > 0 else None
765 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
766 # this reference implementation can be overridden with a provider specific approach
767 if subpath == "artists":
768 if artists := await self.mass.music.artists.library_items(
769 provider=self.instance_id,
770 summary=False,
771 ):
772 return artists
773 # library items not (yet) synced, fallback to direct retrieval
774 return [x async for x in self.get_library_artists()]
775 if subpath == "albums":
776 if albums := await self.mass.music.albums.library_items(
777 provider=self.instance_id,
778 summary=False,
779 ):
780 return albums
781 # library items not (yet) synced, fallback to direct retrieval
782 return [x async for x in self.get_library_albums()]
783 if subpath == "tracks":
784 if tracks := await self.mass.music.tracks.library_items(
785 provider=self.instance_id,
786 summary=False,
787 ):
788 return tracks
789 # library items not (yet) synced, fallback to direct retrieval
790 return [x async for x in self.get_library_tracks()]
791 if subpath == "radios":
792 if radios := await self.mass.music.radio.library_items(
793 provider=self.instance_id,
794 summary=False,
795 ):
796 return radios
797 # library items not (yet) synced, fallback to direct retrieval
798 return [x async for x in self.get_library_radios()]
799 if subpath == "playlists":
800 if playlists := await self.mass.music.playlists.library_items(
801 provider=self.instance_id,
802 summary=False,
803 ):
804 return playlists
805 # library items not (yet) synced, fallback to direct retrieval
806 return [x async for x in self.get_library_playlists()]
807 if subpath == "audiobooks":
808 if audiobooks := await self.mass.music.audiobooks.library_items(
809 provider=self.instance_id,
810 summary=False,
811 ):
812 return audiobooks
813 # library items not (yet) synced, fallback to direct retrieval
814 return [x async for x in self.get_library_audiobooks()]
815 if subpath == "podcasts":
816 if podcasts := await self.mass.music.podcasts.library_items(
817 provider=self.instance_id,
818 summary=False,
819 ):
820 return podcasts
821 # library items not (yet) synced, fallback to direct retrieval
822 return [x async for x in self.get_library_podcasts()]
823 if subpath == "sound_effects":
824 # sound effects are not library-backed, always retrieve them live
825 return [x async for x in self.get_sound_effects()]
826 if subpath == "recommendations" and sub_subpath:
827 # recommendations contents listing
828 return await self.get_recommendation_items(sub_subpath)
829 if subpath == "recommendations":
830 # Main recommendations listing
831 result: list[BrowseFolder] = []
832 recommendations = await self.get_recommendations()
833 for rec in recommendations:
834 result.append(
835 BrowseFolder(
836 item_id=rec.item_id,
837 provider=self.instance_id,
838 name=rec.name,
839 is_playable=rec.is_playable,
840 image=rec.image,
841 path=f"{path}/{rec.item_id}",
842 )
843 )
844 return result
845
846 if subpath:
847 # unknown path
848 msg = "Invalid subpath"
849 raise KeyError(msg)
850
851 # no subpath: return main listing
852 folders: list[BrowseFolder] = []
853 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
854 folders.append(
855 BrowseFolder(
856 item_id="artists",
857 provider=self.instance_id,
858 path=path + "artists",
859 name="",
860 translation_key="artists",
861 is_playable=True,
862 )
863 )
864 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
865 folders.append(
866 BrowseFolder(
867 item_id="albums",
868 provider=self.instance_id,
869 path=path + "albums",
870 name="",
871 translation_key="albums",
872 is_playable=True,
873 )
874 )
875 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
876 folders.append(
877 BrowseFolder(
878 item_id="tracks",
879 provider=self.domain,
880 path=path + "tracks",
881 name="",
882 translation_key="tracks",
883 is_playable=True,
884 )
885 )
886 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
887 folders.append(
888 BrowseFolder(
889 item_id="playlists",
890 provider=self.instance_id,
891 path=path + "playlists",
892 name="",
893 translation_key="playlists",
894 is_playable=True,
895 )
896 )
897 if ProviderFeature.LIBRARY_RADIOS in self.supported_features:
898 folders.append(
899 BrowseFolder(
900 item_id="radios",
901 provider=self.instance_id,
902 path=path + "radios",
903 name="",
904 translation_key="radios",
905 )
906 )
907 if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
908 folders.append(
909 BrowseFolder(
910 item_id="audiobooks",
911 provider=self.instance_id,
912 path=path + "audiobooks",
913 name="",
914 translation_key="audiobooks",
915 )
916 )
917 if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
918 folders.append(
919 BrowseFolder(
920 item_id="podcasts",
921 provider=self.instance_id,
922 path=path + "podcasts",
923 name="",
924 translation_key="podcasts",
925 )
926 )
927 if ProviderFeature.SOUND_EFFECTS in self.supported_features:
928 folders.append(
929 BrowseFolder(
930 item_id="sound_effects",
931 provider=self.instance_id,
932 path=path + "sound_effects",
933 name="",
934 translation_key="sound_effects",
935 )
936 )
937 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
938 folders.append(
939 BrowseFolder(
940 item_id="recommendations",
941 provider=self.instance_id,
942 path=path + "recommendations",
943 name="",
944 translation_key="recommendations",
945 )
946 )
947 if len(folders) == 1:
948 # only one level, return the items directly
949 return await self.browse(folders[0].path)
950 return folders
951
952 async def get_recommendations(self) -> list[RecommendationFolder]:
953 """
954 Get this provider's available recommendation rows, without items.
955
956 Must be fast: return static or cached row descriptors only, without
957 live backend calls. The items for a row are fetched separately
958 through get_recommendation_items.
959
960 Will only be called if ProviderFeature.RECOMMENDATIONS is declared.
961 """
962 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
963 raise NotImplementedError
964 return []
965
966 async def get_recommendation_items(
967 self, item_id: str
968 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
969 """
970 Get the items for a single recommendation row.
971
972 Live backend fetches belong here. Will only be called if
973 ProviderFeature.RECOMMENDATIONS is declared.
974
975 :param item_id: The item_id of the row, as returned by get_recommendations.
976 """
977 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
978 raise NotImplementedError
979 return UniqueList()
980
981 async def sync_library(self, media_type: MediaType) -> None:
982 """Run library sync for this provider."""
983 token = SYNC_RUN_STATE.set(SyncRunState())
984 try:
985 await self._run_library_sync(media_type)
986 finally:
987 SYNC_RUN_STATE.reset(token)
988
989 def report_skipped_sync_item(
990 self, media_type: MediaType, item_id: str | None, err: Exception
991 ) -> None:
992 """
993 Report a library item that was dropped while listing this provider's library.
994
995 Call this from a get_library_*() generator whenever it swallows an error instead of
996 yielding the item, so the failure is reported on the sync task rather than the item
997 looking like it was removed at the provider.
998
999 :param media_type: Media type of the skipped item.
1000 :param item_id: The provider item id of the skipped item, which keeps that single item
1001 out of this sync's deletion pass. Pass None if the item cannot be identified, which
1002 holds back the deletion pass for the entire run instead.
1003 :param err: The error that made the item unusable.
1004 :raises Exception: If this provider declared the error unskippable, so that its own
1005 error handling can act on it instead of the item being skipped.
1006 """
1007 self._handle_sync_item_failure(media_type, item_id, err)
1008 state = sync_run_state()
1009 if item_id:
1010 state.skipped_item_ids.setdefault(media_type, set()).add(item_id)
1011 else:
1012 state.incomplete_media_types.add(media_type)
1013
1014 async def _run_library_sync(self, media_type: MediaType) -> None:
1015 """Sync the given media type into the library and process its deletions."""
1016 # this reference implementation may be overridden
1017 # with a provider specific approach if needed
1018
1019 if not self.mass.music.library_supported(self, media_type):
1020 raise UnsupportedFeaturedException("Library sync not supported for this media type")
1021
1022 sync_state = sync_run_state()
1023 if media_type == MediaType.ARTIST:
1024 cur_db_ids = await self._sync_library_artists()
1025 elif media_type == MediaType.ALBUM:
1026 cur_db_ids = await self._sync_library_albums()
1027 elif media_type == MediaType.TRACK:
1028 cur_db_ids = await self._sync_library_tracks()
1029 elif media_type == MediaType.PLAYLIST:
1030 cur_db_ids = await self._sync_library_playlists()
1031 elif media_type == MediaType.PODCAST:
1032 cur_db_ids = await self._sync_library_podcasts()
1033 elif media_type == MediaType.RADIO:
1034 cur_db_ids = await self._sync_library_radios()
1035 elif media_type == MediaType.AUDIOBOOK:
1036 cur_db_ids = await self._sync_library_audiobooks()
1037 else:
1038 # this should not happen but catch it anyways
1039 raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}")
1040
1041 # process deletions (= no longer in library)
1042 update_current_task_progress_text("Checking library deletions")
1043 controller = self.mass.music.get_controller(media_type)
1044 await self._keep_skipped_items(media_type, cur_db_ids)
1045 prev_library_items: list[int] | None
1046 if media_type in sync_state.incomplete_media_types:
1047 # a skipped item is missing from cur_db_ids just like a deleted one, but it is
1048 # still in the provider's library, so deleting it would throw away valid content
1049 if self.library_sync_deletions_enabled():
1050 summary = f"{sync_state.failures} item(s) could not be synced"
1051 self.logger.warning("Skipping deletions for %s: %s", self.name, summary)
1052 report_current_task_failure(f"Deletions skipped: {summary}")
1053 # merge this run's id's into the stored ones instead of replacing them: that
1054 # keeps both the deletions this run could not tell apart from its own failures
1055 # and the items it saw for the first time, so a later complete run finds either
1056 if prev_library_items := await self.mass.cache.get(
1057 key=media_type.value,
1058 provider=self.instance_id,
1059 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1060 ):
1061 cur_db_ids.update(prev_library_items)
1062 elif self.library_sync_deletions_enabled():
1063 if prev_library_items := await self.mass.cache.get(
1064 key=media_type.value,
1065 provider=self.instance_id,
1066 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1067 ):
1068 for db_id in prev_library_items:
1069 if db_id not in cur_db_ids:
1070 try:
1071 library_item = await controller.get_library_item(db_id)
1072 except MediaNotFoundError:
1073 # edge case: the item is (already) removed from MA library as well
1074 continue
1075 # check if we have other provider-mappings (marked as in-library)
1076 remaining_providers_in_library = {
1077 x.provider_instance
1078 for x in library_item.provider_mappings
1079 if x.provider_instance != self.instance_id and x.in_library
1080 }
1081 if not remaining_providers_in_library and not self.is_streaming_provider:
1082 # for non-streaming providers (local files, library-middlemen
1083 # like subsonic/jellyfin/plex) an item removed from the provider
1084 # is actually gone; fully remove it to avoid dangling records
1085 # that stay visible in artist/album views where in_library is
1086 # not filtered on
1087 await controller.remove_item_from_library(db_id)
1088 else:
1089 if not remaining_providers_in_library and library_item.favorite:
1090 # unmark as favorite since no providers have it in library
1091 await controller.set_favorite(db_id, False)
1092 # unmark this provider mapping as in_library = False
1093 # we keep it in the library database so we can keep the metadata
1094 for prov_map in library_item.provider_mappings:
1095 if prov_map.provider_instance == self.instance_id:
1096 prov_map.in_library = False
1097 await controller.set_provider_mappings(
1098 db_id, library_item.provider_mappings
1099 )
1100 await asyncio.sleep(0) # yield to eventloop
1101 # store current list of id's in cache so we can track changes
1102 await self.mass.cache.set(
1103 key=media_type.value,
1104 data=list(cur_db_ids),
1105 provider=self.instance_id,
1106 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1107 )
1108 update_current_task_progress_text("Finalizing library sync")
1109
1110 def _update_sync_task_item_status(
1111 self, media_type: MediaType, processed_items: int, item_name: str | None = None
1112 ) -> None:
1113 """Update task text for the item currently being synced."""
1114 message = f"Processed {processed_items} {media_type.value}s"
1115 if item_name:
1116 message = f"{message}: {item_name}"
1117 update_current_task_progress_text(message)
1118
1119 def _handle_sync_item_failure(
1120 self, media_type: MediaType, item_ref: str | None, err: Exception
1121 ) -> None:
1122 """
1123 Log a non-fatal sync failure and record it on the active background task.
1124
1125 :raises Exception: If the provider declared this error unskippable, so that its own
1126 error handling can act on it instead of the item being skipped.
1127 """
1128 if isinstance(err, self.unskippable_sync_errors):
1129 raise err
1130 state = sync_run_state()
1131 state.failures += 1
1132 error_detail = describe_sync_error(err)
1133 if state.failures <= MAX_LOGGED_SYNC_FAILURES:
1134 if isinstance(err, MusicAssistantError):
1135 self.logger.warning(
1136 "Skipping sync of %s %s - error details: %s",
1137 media_type.value,
1138 item_ref,
1139 error_detail,
1140 )
1141 else:
1142 # not one of our own errors: usually a provider choking on its own api
1143 # payload, but the per-item library writes raise the same way, so log the
1144 # traceback (on debug) to make the actual origin traceable
1145 self.logger.error(
1146 "Skipping sync of %s %s - unexpected error: %s",
1147 media_type.value,
1148 item_ref,
1149 error_detail,
1150 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
1151 )
1152 report_current_task_failure(
1153 f"Failed to sync {media_type.value} {item_ref or '<unknown>'}: {error_detail}"
1154 )
1155
1156 async def _keep_skipped_items(self, media_type: MediaType, cur_db_ids: set[int]) -> None:
1157 """
1158 Add the library id's of the items the provider skipped to this run's result set.
1159
1160 A skipped item is still in the provider's library, so leaving it out would let the
1161 deletion pass read it as removed.
1162 """
1163 if not (skipped_item_ids := sorted(sync_run_state().skipped_item_ids.get(media_type, ()))):
1164 return
1165 controller = self.mass.music.get_controller(media_type)
1166 for index in range(0, len(skipped_item_ids), SKIPPED_ITEM_QUERY_LIMIT):
1167 for library_item in await controller.get_library_items_by_prov_id(
1168 provider_instance=self.instance_id,
1169 provider_item_ids=skipped_item_ids[index : index + SKIPPED_ITEM_QUERY_LIMIT],
1170 limit=SKIPPED_ITEM_QUERY_LIMIT,
1171 ):
1172 cur_db_ids.add(int(library_item.item_id))
1173
1174 def _protect_failed_sync_item(
1175 self,
1176 media_type: MediaType,
1177 provider_item_id: str | None,
1178 library_item_id: int | None,
1179 cur_db_ids: set[int],
1180 ) -> None:
1181 """Keep a failed item out of this run's deletion pass."""
1182 if library_item_id is not None:
1183 cur_db_ids.add(library_item_id)
1184 elif provider_item_id:
1185 sync_run_state().skipped_item_ids.setdefault(media_type, set()).add(provider_item_id)
1186 else:
1187 sync_run_state().incomplete_media_types.add(media_type)
1188
1189 async def _sync_item_genres(
1190 self,
1191 media_type: MediaType,
1192 provider_item_id: str,
1193 library_item_id: int,
1194 fallback_genres: set[str] | None = None,
1195 ) -> None:
1196 try:
1197 genre_names = await self.get_item_genre_names(media_type, provider_item_id)
1198 except NotImplementedError:
1199 if fallback_genres is None:
1200 return
1201 genre_names = fallback_genres
1202
1203 await self.mass.music.genres.sync_media_item_genres(
1204 media_type, library_item_id, set(genre_names)
1205 )
1206
1207 async def _sync_library_artists(self) -> set[int]:
1208 """Sync Library Artists to Music Assistant library."""
1209 self.logger.debug("Start sync of Artists to Music Assistant library.")
1210 cur_db_ids: set[int] = set()
1211 item_count = 0
1212 async for prov_item in self.get_library_artists():
1213 item_count += 1
1214 self._update_sync_task_item_status(MediaType.ARTIST, item_count, prov_item.name)
1215 db_id: int | None = None
1216 try:
1217 sync_details = await self.mass.music.artists.get_library_item_sync_details(
1218 prov_item.provider_mappings,
1219 )
1220 db_id = sync_details.item_id if sync_details else None
1221 # batch all writes for this item into a single commit
1222 async with self.mass.music.database.deferred_commit():
1223 if not sync_details:
1224 # add item to the library
1225 for prov_map in prov_item.provider_mappings:
1226 prov_map.in_library = True
1227 library_item = await self.mass.music.artists.add_item_to_library(prov_item)
1228 db_id = int(library_item.item_id)
1229 favorite = library_item.favorite
1230 elif self._library_item_needs_update(sync_details, prov_item):
1231 library_item = await self.mass.music.artists.update_item_in_library(
1232 sync_details.item_id, prov_item
1233 )
1234 db_id = int(library_item.item_id)
1235 favorite = library_item.favorite
1236 else:
1237 db_id = sync_details.item_id
1238 favorite = sync_details.favorite
1239 cur_db_ids.add(db_id)
1240 if not favorite and prov_item.favorite:
1241 # existing library item not favorite but should be
1242 await self.mass.music.artists.set_favorite(db_id, True)
1243 fallback_genres = (
1244 set(prov_item.metadata.genres)
1245 if prov_item.metadata and prov_item.metadata.genres
1246 else None
1247 )
1248 await self._sync_item_genres(
1249 MediaType.ARTIST,
1250 prov_item.item_id,
1251 db_id,
1252 fallback_genres,
1253 )
1254 await asyncio.sleep(0) # yield to eventloop
1255 except Exception as err:
1256 self._handle_sync_item_failure(MediaType.ARTIST, prov_item.uri, err)
1257 self._protect_failed_sync_item(
1258 MediaType.ARTIST, prov_item.item_id, db_id, cur_db_ids
1259 )
1260 return cur_db_ids
1261
1262 def library_sync_album_tracks_enabled(self) -> bool:
1263 """Return whether all tracks of an album should be imported into the library."""
1264 return bool(
1265 self.config.get_value(
1266 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.key,
1267 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.default_value,
1268 )
1269 )
1270
1271 async def _sync_library_albums(self) -> set[int]:
1272 """Sync Library Albums to Music Assistant library."""
1273 self.logger.debug("Start sync of Albums to Music Assistant library.")
1274 cur_db_ids: set[int] = set()
1275 sync_album_tracks = self.library_sync_album_tracks_enabled()
1276 item_count = 0
1277 async for prov_item in self.get_library_albums():
1278 item_count += 1
1279 self._update_sync_task_item_status(MediaType.ALBUM, item_count, prov_item.name)
1280 db_id: int | None = None
1281 try:
1282 sync_details = await self.mass.music.albums.get_library_item_sync_details(
1283 prov_item.provider_mappings,
1284 )
1285 db_id = sync_details.item_id if sync_details else None
1286 # batch all writes for this item into a single commit
1287 async with self.mass.music.database.deferred_commit():
1288 if not sync_details:
1289 # add item to the library
1290 for prov_map in prov_item.provider_mappings:
1291 prov_map.in_library = True
1292 library_item = await self.mass.music.albums.add_item_to_library(prov_item)
1293 db_id = int(library_item.item_id)
1294 favorite = library_item.favorite
1295 elif self._library_item_needs_update(sync_details, prov_item):
1296 library_item = await self.mass.music.albums.update_item_in_library(
1297 sync_details.item_id, prov_item
1298 )
1299 db_id = int(library_item.item_id)
1300 favorite = library_item.favorite
1301 else:
1302 db_id = sync_details.item_id
1303 favorite = sync_details.favorite
1304 cur_db_ids.add(db_id)
1305 if not favorite and prov_item.favorite:
1306 # existing library item not favorite but should be
1307 await self.mass.music.albums.set_favorite(db_id, True)
1308 fallback_genres = (
1309 set(prov_item.metadata.genres)
1310 if prov_item.metadata and prov_item.metadata.genres
1311 else None
1312 )
1313 await self._sync_item_genres(
1314 MediaType.ALBUM,
1315 prov_item.item_id,
1316 db_id,
1317 fallback_genres,
1318 )
1319 await asyncio.sleep(0) # yield to eventloop
1320 except Exception as err:
1321 self._handle_sync_item_failure(MediaType.ALBUM, prov_item.uri, err)
1322 self._protect_failed_sync_item(
1323 MediaType.ALBUM, prov_item.item_id, db_id, cur_db_ids
1324 )
1325 continue
1326 # optionally add album tracks to library. the album is already collected here,
1327 # so failing to import its tracks does not make the album result set incomplete
1328 if sync_album_tracks:
1329 try:
1330 await self.import_album_tracks(prov_item.item_id, prov_item.name)
1331 except Exception as err:
1332 self._handle_sync_item_failure(MediaType.ALBUM, prov_item.uri, err)
1333 return cur_db_ids
1334
1335 async def import_album_tracks(self, prov_album_id: str, album_name: str | None = None) -> None:
1336 """
1337 Import all tracks of the given (provider) album into the Music Assistant library.
1338
1339 :param prov_album_id: The provider item id of the album.
1340 :param album_name: Optional album name, used for logging/progress only.
1341 """
1342 self.logger.debug(
1343 "Importing Album Tracks into the Music Assistant library for album %s.",
1344 album_name or prov_album_id,
1345 )
1346 for item_count, prov_track in enumerate(
1347 await self.get_album_tracks(prov_album_id), start=1
1348 ):
1349 self._update_sync_task_item_status(MediaType.TRACK, item_count, prov_track.name)
1350 try:
1351 sync_details = await self.mass.music.tracks.get_library_item_sync_details(
1352 prov_track.provider_mappings,
1353 )
1354 # batch all writes for this item into a single commit
1355 async with self.mass.music.database.deferred_commit():
1356 if not sync_details:
1357 # add item to the library
1358 for prov_map in prov_track.provider_mappings:
1359 prov_map.in_library = True
1360 library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
1361 db_id = int(library_track.item_id)
1362 elif not self._check_provider_mappings(sync_details, prov_track, True):
1363 # existing library track but provider mapping doesn't match
1364 library_track = await self.mass.music.tracks.update_item_in_library(
1365 sync_details.item_id, prov_track
1366 )
1367 db_id = int(library_track.item_id)
1368 else:
1369 db_id = sync_details.item_id
1370 fallback_genres = (
1371 set(prov_track.metadata.genres)
1372 if prov_track.metadata and prov_track.metadata.genres
1373 else None
1374 )
1375 await self._sync_item_genres(
1376 MediaType.TRACK,
1377 prov_track.item_id,
1378 db_id,
1379 fallback_genres,
1380 )
1381 await asyncio.sleep(0) # yield to eventloop
1382 except Exception as err:
1383 self._handle_sync_item_failure(MediaType.TRACK, prov_track.uri, err)
1384
1385 def _validate_audiobook_author_narrator_types(self, prov_item: Audiobook) -> None:
1386 """
1387 Validate of correct artist and artist types.
1388
1389 If a provider supports artists of type Author or Narrator, they have to be part of an audiobook instance.
1390 Otherwise only strings are allowed.
1391 """
1392 if ArtistType.AUTHOR in self.supported_artist_types and not all(
1393 (isinstance(author, Artist) and author.artist_type == ArtistType.AUTHOR)
1394 for author in prov_item.authors
1395 ):
1396 raise InvalidDataError(
1397 f"Provider {self.name} supports ArtistType.AUTHOR, but"
1398 f" item {prov_item.name} does not exclusively provide Artist instances "
1399 "with ArtistType.AUTHOR set."
1400 )
1401 if ArtistType.NARRATOR in self.supported_artist_types and not all(
1402 (isinstance(narrator, Artist) and narrator.artist_type == ArtistType.NARRATOR)
1403 for narrator in prov_item.narrators
1404 ):
1405 raise InvalidDataError(
1406 f"Provider {self.name} supports ArtistType.NARRATOR, but"
1407 f" item {prov_item.name} does not exclusively provide Artist instances "
1408 "with ArtistType.NARRATOR set."
1409 )
1410 if ArtistType.AUTHOR not in self.supported_artist_types and not all(
1411 isinstance(author, str) for author in prov_item.authors
1412 ):
1413 raise InvalidDataError(
1414 f"Provider {self.name} does not support artists of type author, but"
1415 f" item {prov_item.name} does not exclusively provide strings."
1416 )
1417 if ArtistType.NARRATOR not in self.supported_artist_types and not all(
1418 isinstance(narrator, str) for narrator in prov_item.narrators
1419 ):
1420 raise InvalidDataError(
1421 f"Provider {self.name} does not support artists of type narrator, but"
1422 f" item {prov_item.name} does not exclusively provide strings."
1423 )
1424
1425 async def _sync_library_audiobooks(self) -> set[int]:
1426 """Sync Library Audiobooks to Music Assistant library."""
1427 self.logger.debug("Start sync of Audiobooks to Music Assistant library.")
1428 cur_db_ids: set[int] = set()
1429 item_count = 0
1430 async for prov_item in self.get_library_audiobooks():
1431 item_count += 1
1432 self._update_sync_task_item_status(MediaType.AUDIOBOOK, item_count, prov_item.name)
1433 db_id: int | None = None
1434 try:
1435 sync_details = cast(
1436 "AudiobookSyncDetails | None",
1437 await self.mass.music.audiobooks.get_library_item_sync_details(
1438 prov_item.provider_mappings,
1439 ),
1440 )
1441 db_id = sync_details.item_id if sync_details else None
1442 self._validate_audiobook_author_narrator_types(prov_item)
1443 # batch all writes for this item into a single commit
1444 async with self.mass.music.database.deferred_commit():
1445 if not sync_details:
1446 # add item to the library
1447 for prov_map in prov_item.provider_mappings:
1448 prov_map.in_library = True
1449 library_item = await self.mass.music.audiobooks.add_item_to_library(
1450 prov_item
1451 )
1452 db_id = int(library_item.item_id)
1453 favorite = library_item.favorite
1454 lib_fully_played = library_item.fully_played
1455 lib_resume_position_ms = library_item.resume_position_ms
1456 elif self._library_item_needs_update(sync_details, prov_item):
1457 library_item = await self.mass.music.audiobooks.update_item_in_library(
1458 sync_details.item_id, prov_item
1459 )
1460 db_id = int(library_item.item_id)
1461 favorite = library_item.favorite
1462 lib_fully_played = library_item.fully_played
1463 lib_resume_position_ms = library_item.resume_position_ms
1464 else:
1465 # Detect, if stored authors/narrators are plain strings but the provider
1466 # now supplies full Artist objects, i.e. artist support changed.
1467 prov_author = prov_item.authors[0] if prov_item.authors else None
1468 prov_narrator = prov_item.narrators[0] if prov_item.narrators else None
1469 if (sync_details.author_is_str and not isinstance(prov_author, str)) or (
1470 sync_details.narrator_is_str and not isinstance(prov_narrator, str)
1471 ):
1472 library_item = await self.mass.music.audiobooks.update_item_in_library(
1473 sync_details.item_id, prov_item
1474 )
1475 db_id = int(library_item.item_id)
1476 favorite = library_item.favorite
1477 lib_fully_played = library_item.fully_played
1478 lib_resume_position_ms = library_item.resume_position_ms
1479 else:
1480 db_id = sync_details.item_id
1481 favorite = sync_details.favorite
1482 lib_fully_played = sync_details.fully_played
1483 lib_resume_position_ms = sync_details.resume_position_ms
1484
1485 cur_db_ids.add(db_id)
1486 if not favorite and prov_item.favorite:
1487 # existing library item not favorite but should be
1488 await self.mass.music.audiobooks.set_favorite(db_id, True)
1489 # check if resume_position_ms or fully_played changed
1490 if (
1491 prov_item.resume_position_ms is not None
1492 and prov_item.fully_played is not None
1493 and (
1494 lib_resume_position_ms != prov_item.resume_position_ms
1495 or lib_fully_played != prov_item.fully_played
1496 )
1497 ):
1498 await self.mass.music.audiobooks.update_item_in_library(db_id, prov_item)
1499
1500 fallback_genres = (
1501 set(prov_item.metadata.genres)
1502 if prov_item.metadata and prov_item.metadata.genres
1503 else None
1504 )
1505 await self._sync_item_genres(
1506 MediaType.AUDIOBOOK,
1507 prov_item.item_id,
1508 db_id,
1509 fallback_genres,
1510 )
1511
1512 await asyncio.sleep(0) # yield to eventloop
1513 except Exception as err:
1514 self._handle_sync_item_failure(MediaType.AUDIOBOOK, prov_item.uri, err)
1515 self._protect_failed_sync_item(
1516 MediaType.AUDIOBOOK, prov_item.item_id, db_id, cur_db_ids
1517 )
1518 return cur_db_ids
1519
1520 async def _sync_library_playlists(self) -> set[int]:
1521 """Sync Library Playlists to Music Assistant library."""
1522 self.logger.debug("Start sync of Playlists to Music Assistant library.")
1523 conf_sync_playlist_tracks = self.config.get_value(
1524 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS.key,
1525 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS.default_value,
1526 )
1527 conf_sync_playlist_tracks = cast("list[str]", conf_sync_playlist_tracks)
1528 cur_db_ids: set[int] = set()
1529 item_count = 0
1530 async for prov_item in self.get_library_playlists():
1531 item_count += 1
1532 self._update_sync_task_item_status(MediaType.PLAYLIST, item_count, prov_item.name)
1533 db_id: int | None = None
1534 try:
1535 library_item = await self.mass.music.playlists.get_library_item_by_prov_mappings(
1536 prov_item.provider_mappings,
1537 )
1538 db_id = int(library_item.item_id) if library_item else None
1539 # batch all writes for this item into a single commit
1540 async with self.mass.music.database.deferred_commit():
1541 if not library_item:
1542 # add item to the library
1543 for prov_map in prov_item.provider_mappings:
1544 prov_map.in_library = True
1545 library_item = await self.mass.music.playlists.add_item_to_library(
1546 prov_item
1547 )
1548 elif (
1549 self._library_item_needs_update(library_item, prov_item)
1550 # or the supported mediatypes changed
1551 or prov_item.supported_mediatypes != library_item.supported_mediatypes
1552 ):
1553 library_item = await self.mass.music.playlists.update_item_in_library(
1554 library_item.item_id, prov_item
1555 )
1556 elif (
1557 prov_item.is_dynamic
1558 and not library_item.is_editable
1559 and (
1560 prov_item.name != library_item.name
1561 or prov_item.metadata.images != library_item.metadata.images
1562 )
1563 ):
1564 # the provider is the sole source of truth for non-editable dynamic
1565 # playlists (e.g. Pandora/personalized-radio stations): overwrite=True
1566 # replaces the full stored record (not just name/images), which is fine
1567 # here since there's no local customization on these to lose. Restricted
1568 # to is_dynamic so static non-editable playlists (e.g. provider
1569 # "favorites") keep their locally-enriched metadata/images.
1570 library_item = await self.mass.music.playlists.update_item_in_library(
1571 library_item.item_id, prov_item, overwrite=True
1572 )
1573 db_id = int(library_item.item_id)
1574 cur_db_ids.add(db_id)
1575 if not library_item.favorite and prov_item.favorite:
1576 # existing library item not favorite but should be
1577 await self.mass.music.playlists.set_favorite(library_item.item_id, True)
1578 await asyncio.sleep(0) # yield to eventloop
1579 except Exception as err:
1580 self._handle_sync_item_failure(MediaType.PLAYLIST, prov_item.uri, err)
1581 self._protect_failed_sync_item(
1582 MediaType.PLAYLIST, prov_item.item_id, db_id, cur_db_ids
1583 )
1584 continue
1585 # optionally sync playlist tracks. the playlist is already collected here, so
1586 # failing on its tracks does not make the playlist result set incomplete
1587 if (
1588 prov_item.name in conf_sync_playlist_tracks
1589 or prov_item.uri in conf_sync_playlist_tracks
1590 ):
1591 try:
1592 await self._sync_playlist_tracks(prov_item)
1593 except Exception as err:
1594 self._handle_sync_item_failure(MediaType.PLAYLIST, prov_item.uri, err)
1595 return cur_db_ids
1596
1597 async def _sync_playlist_tracks(self, provider_playlist: Playlist) -> None:
1598 """Sync Playlist Tracks to Music Assistant library."""
1599 self.logger.debug(
1600 "Start sync of Playlist Tracks to Music Assistant library for playlist %s.",
1601 provider_playlist.name,
1602 )
1603 item_count = 0
1604 async for _prov_track in self.iter_playlist_tracks(provider_playlist.item_id):
1605 prov_track: PlaylistPlayableItem | Podcast = _prov_track
1606 item_count += 1
1607 try:
1608 if isinstance(_prov_track, PodcastEpisode):
1609 # In MA, only full podcasts can be synced to the library
1610 prov_track = await self.get_podcast(_prov_track.podcast.item_id)
1611 self._update_sync_task_item_status(MediaType.TRACK, item_count, prov_track.name)
1612 controller = self.mass.music.get_controller(prov_track.media_type)
1613 sync_details = await controller.get_library_item_sync_details(
1614 prov_track.provider_mappings,
1615 )
1616 # batch all writes for this item into a single commit
1617 async with self.mass.music.database.deferred_commit():
1618 if not sync_details:
1619 # add item to the library
1620 for prov_map in prov_track.provider_mappings:
1621 prov_map.in_library = True
1622 library_track = await controller.add_item_to_library(prov_track) # type: ignore[arg-type]
1623 db_id = int(library_track.item_id)
1624 elif not self._check_provider_mappings(sync_details, prov_track, True):
1625 # existing library track but provider mapping doesn't match
1626 library_track = await controller.update_item_in_library(
1627 sync_details.item_id,
1628 prov_track, # type: ignore[arg-type]
1629 )
1630 db_id = int(library_track.item_id)
1631 else:
1632 db_id = sync_details.item_id
1633 fallback_genres = (
1634 set(prov_track.metadata.genres)
1635 if prov_track.metadata and prov_track.metadata.genres
1636 else None
1637 )
1638 await self._sync_item_genres(
1639 MediaType.TRACK,
1640 prov_track.item_id,
1641 db_id,
1642 fallback_genres,
1643 )
1644 await asyncio.sleep(0) # yield to eventloop
1645 except Exception as err:
1646 self._handle_sync_item_failure(MediaType.TRACK, prov_track.uri, err)
1647
1648 async def _sync_library_tracks(self) -> set[int]:
1649 """Sync Library Tracks to Music Assistant library."""
1650 self.logger.debug("Start sync of Tracks to Music Assistant library.")
1651 cur_db_ids: set[int] = set()
1652 item_count = 0
1653 async for prov_item in self.get_library_tracks():
1654 item_count += 1
1655 self._update_sync_task_item_status(MediaType.TRACK, item_count, prov_item.name)
1656 db_id: int | None = None
1657 try:
1658 sync_details = cast(
1659 "TrackSyncDetails | None",
1660 await self.mass.music.tracks.get_library_item_sync_details(
1661 prov_item.provider_mappings,
1662 ),
1663 )
1664 db_id = sync_details.item_id if sync_details else None
1665 if not sync_details and not prov_item.available:
1666 # skip unavailable tracks
1667 # TODO: do we want to search for substitutes at this point ?
1668 self.logger.debug(
1669 "Skipping sync of track %s because it is unavailable",
1670 prov_item.uri,
1671 )
1672 continue
1673 # batch all writes for this item into a single commit
1674 async with self.mass.music.database.deferred_commit():
1675 if not sync_details:
1676 # add item to the library
1677 for prov_map in prov_item.provider_mappings:
1678 prov_map.in_library = True
1679 library_item = await self.mass.music.tracks.add_item_to_library(prov_item)
1680 db_id = int(library_item.item_id)
1681 favorite = library_item.favorite
1682 elif (
1683 self._library_item_needs_update(sync_details, prov_item)
1684 # or backfill a missing album(_tracks) link for existing tracks
1685 or (prov_item.album and not sync_details.has_album)
1686 # or backfill missing track_artists link(s) for existing tracks
1687 or (prov_item.artists and not sync_details.has_artists)
1688 ):
1689 library_item = await self.mass.music.tracks.update_item_in_library(
1690 sync_details.item_id, prov_item
1691 )
1692 db_id = int(library_item.item_id)
1693 favorite = library_item.favorite
1694 else:
1695 db_id = sync_details.item_id
1696 favorite = sync_details.favorite
1697 cur_db_ids.add(db_id)
1698 if not favorite and prov_item.favorite:
1699 # existing library item not favorite but should be
1700 await self.mass.music.tracks.set_favorite(db_id, True)
1701 fallback_genres = (
1702 set(prov_item.metadata.genres)
1703 if prov_item.metadata and prov_item.metadata.genres
1704 else None
1705 )
1706 await self._sync_item_genres(
1707 MediaType.TRACK,
1708 prov_item.item_id,
1709 db_id,
1710 fallback_genres,
1711 )
1712 await asyncio.sleep(0) # yield to eventloop
1713 except Exception as err:
1714 self._handle_sync_item_failure(MediaType.TRACK, prov_item.uri, err)
1715 self._protect_failed_sync_item(
1716 MediaType.TRACK, prov_item.item_id, db_id, cur_db_ids
1717 )
1718 return cur_db_ids
1719
1720 async def _sync_library_podcasts(self) -> set[int]:
1721 """Sync Library Podcasts to Music Assistant library."""
1722 self.logger.debug("Start sync of Podcasts to Music Assistant library.")
1723 cur_db_ids: set[int] = set()
1724 item_count = 0
1725 async for prov_item in self.get_library_podcasts():
1726 item_count += 1
1727 self._update_sync_task_item_status(MediaType.PODCAST, item_count, prov_item.name)
1728 db_id: int | None = None
1729 try:
1730 sync_details = await self.mass.music.podcasts.get_library_item_sync_details(
1731 prov_item.provider_mappings,
1732 )
1733 db_id = sync_details.item_id if sync_details else None
1734 # batch all writes for this item into a single commit
1735 async with self.mass.music.database.deferred_commit():
1736 if not sync_details:
1737 # add item to the library
1738 for prov_map in prov_item.provider_mappings:
1739 prov_map.in_library = True
1740 library_item = await self.mass.music.podcasts.add_item_to_library(prov_item)
1741 db_id = int(library_item.item_id)
1742 favorite = library_item.favorite
1743 elif self._library_item_needs_update(sync_details, prov_item):
1744 library_item = await self.mass.music.podcasts.update_item_in_library(
1745 sync_details.item_id, prov_item
1746 )
1747 db_id = int(library_item.item_id)
1748 favorite = library_item.favorite
1749 else:
1750 db_id = sync_details.item_id
1751 favorite = sync_details.favorite
1752 cur_db_ids.add(db_id)
1753 if not favorite and prov_item.favorite:
1754 # existing library item not favorite but should be
1755 await self.mass.music.podcasts.set_favorite(db_id, True)
1756 fallback_genres = (
1757 set(prov_item.metadata.genres)
1758 if prov_item.metadata and prov_item.metadata.genres
1759 else None
1760 )
1761 await self._sync_item_genres(
1762 MediaType.PODCAST,
1763 prov_item.item_id,
1764 db_id,
1765 fallback_genres,
1766 )
1767 await asyncio.sleep(0) # yield to eventloop
1768 except Exception as err:
1769 self._handle_sync_item_failure(MediaType.PODCAST, prov_item.uri, err)
1770 self._protect_failed_sync_item(
1771 MediaType.PODCAST, prov_item.item_id, db_id, cur_db_ids
1772 )
1773 continue
1774 # the podcast is already collected here, so a feed that fails to deliver its
1775 # episodes does not make the podcast result set incomplete
1776 try:
1777 # precache podcast episodes
1778 async for _ in self.mass.music.podcasts.episodes(str(db_id), "library"):
1779 await asyncio.sleep(0) # yield to eventloop
1780 except Exception as err:
1781 self._handle_sync_item_failure(MediaType.PODCAST, prov_item.uri, err)
1782 return cur_db_ids
1783
1784 async def _sync_library_radios(self) -> set[int]:
1785 """Sync Library Radios to Music Assistant library."""
1786 self.logger.debug("Start sync of Radios to Music Assistant library.")
1787 cur_db_ids: set[int] = set()
1788 item_count = 0
1789 async for prov_item in self.get_library_radios():
1790 item_count += 1
1791 self._update_sync_task_item_status(MediaType.RADIO, item_count, prov_item.name)
1792 db_id: int | None = None
1793 try:
1794 library_item = await self.mass.music.radio.get_library_item_by_prov_mappings(
1795 prov_item.provider_mappings,
1796 )
1797 db_id = int(library_item.item_id) if library_item else None
1798 # batch all writes for this item into a single commit
1799 async with self.mass.music.database.deferred_commit():
1800 if not library_item:
1801 # add item to the library
1802 for prov_map in prov_item.provider_mappings:
1803 prov_map.in_library = True
1804 library_item = await self.mass.music.radio.add_item_to_library(prov_item)
1805 elif prov_item.is_dynamic and (
1806 not library_item.is_dynamic
1807 or prov_item.name != library_item.name
1808 or prov_item.metadata.images != library_item.metadata.images
1809 ):
1810 # must overwrite: merging keeps mappings that serve the wrong tracks
1811 for prov_map in prov_item.provider_mappings:
1812 prov_map.in_library = True # overwrite re-inserts the rows
1813 library_item = await self.mass.music.radio.update_item_in_library(
1814 library_item.item_id, prov_item, overwrite=True
1815 )
1816 elif self._library_item_needs_update(library_item, prov_item) or (
1817 library_item.is_dynamic and not prov_item.is_dynamic
1818 ):
1819 # a station leaving dynamic mode is no longer provider-owned, so merge
1820 library_item = await self.mass.music.radio.update_item_in_library(
1821 library_item.item_id, prov_item
1822 )
1823 db_id = int(library_item.item_id)
1824 cur_db_ids.add(db_id)
1825 if not library_item.favorite and prov_item.favorite:
1826 # existing library item not favorite but should be
1827 await self.mass.music.radio.set_favorite(library_item.item_id, True)
1828 await asyncio.sleep(0) # yield to eventloop
1829
1830 except Exception as err:
1831 self._handle_sync_item_failure(MediaType.RADIO, prov_item.uri, err)
1832 self._protect_failed_sync_item(
1833 MediaType.RADIO, prov_item.item_id, db_id, cur_db_ids
1834 )
1835 return cur_db_ids
1836
1837 # DO NOT OVERRIDE BELOW
1838
1839 def get_default_library_sync_schedule(self, media_type: MediaType) -> TaskSchedule:
1840 """Return the default recurring schedule for library sync tasks of this provider."""
1841 if not self.mass.music.library_supported(self, media_type):
1842 raise UnsupportedFeaturedException(
1843 f"Library sync is not supported for {media_type} on {self.instance_id}"
1844 )
1845 return TaskSchedule.hourly(every=12)
1846
1847 def library_sync_deletions_enabled(self) -> bool:
1848 """Return if Library sync deletions is enabled for this provider."""
1849 conf_value = self.config.get_value(
1850 CONF_ENTRY_LIBRARY_SYNC_DELETIONS.key, CONF_ENTRY_LIBRARY_SYNC_DELETIONS.default_value
1851 )
1852 return bool(conf_value)
1853
1854 async def iter_playlist_tracks(
1855 self,
1856 prov_playlist_id: str,
1857 ) -> AsyncGenerator[PlaylistPlayableItem]:
1858 """Iterate playlist tracks for the given provider playlist id."""
1859 page = 0
1860 while True:
1861 tracks = await self.get_playlist_tracks(
1862 prov_playlist_id,
1863 page=page,
1864 )
1865 if not tracks:
1866 break
1867 for track in tracks:
1868 yield track
1869 page += 1
1870
1871 def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]:
1872 """Return library generator for given media_type."""
1873 if media_type == MediaType.ARTIST:
1874 return self.get_library_artists()
1875 if media_type == MediaType.ALBUM:
1876 return self.get_library_albums()
1877 if media_type == MediaType.TRACK:
1878 return self.get_library_tracks()
1879 if media_type == MediaType.PLAYLIST:
1880 return self.get_library_playlists()
1881 if media_type == MediaType.RADIO:
1882 return self.get_library_radios()
1883 if media_type == MediaType.AUDIOBOOK:
1884 return self.get_library_audiobooks()
1885 if media_type == MediaType.PODCAST:
1886 return self.get_library_podcasts()
1887 raise NotImplementedError
1888
1889 def _library_item_needs_update(
1890 self, library_item: MediaItemType | LibraryItemSyncDetails, prov_item: MediaItemType
1891 ) -> bool:
1892 """Return True if the library item needs an update from the given provider item."""
1893 if not self._check_provider_mappings(library_item, prov_item, True):
1894 # provider mapping doesn't match the library item
1895 return True
1896 # the item's date_added changed on the provider
1897 return bool(prov_item.date_added and library_item.date_added != prov_item.date_added)
1898
1899 def _check_provider_mappings(
1900 self,
1901 library_item: MediaItemType | LibraryItemSyncDetails,
1902 provider_item: MediaItemType,
1903 in_library: bool,
1904 ) -> bool:
1905 """Check if provider mapping(s) are consistent between library and provider items."""
1906 for provider_mapping in provider_item.provider_mappings:
1907 if provider_mapping.item_id != provider_item.item_id:
1908 # this should never happen, but guard against it
1909 raise MusicAssistantError("Inconsistent provider mapping item_id found")
1910 if provider_mapping.provider_instance != self.instance_id:
1911 # this should never happen, but guard against it
1912 raise MusicAssistantError("Inconsistent provider mapping instance_id found")
1913 # check if the provider mapping matches the library item
1914 provider_mapping.in_library = in_library
1915 library_mapping = next(
1916 (
1917 x
1918 for x in library_item.provider_mappings
1919 if x.provider_instance == provider_mapping.provider_instance
1920 and x.item_id == provider_mapping.item_id
1921 ),
1922 None,
1923 )
1924 if not library_mapping:
1925 return False
1926 if provider_mapping.in_library != library_mapping.in_library:
1927 # in-library status doesn't match
1928 return False
1929 if provider_mapping.is_unique != library_mapping.is_unique:
1930 # unique status doesn't match
1931 return False
1932 # check if the library item has all provider instances mappings
1933 is_unique = provider_mapping.is_unique or (not self.is_streaming_provider)
1934 if not is_unique:
1935 # for streaming providers we need to make sure all provider instances
1936 # for this domain are represented in the provider mappings
1937 prov_instances = self.mass.music.get_provider_instances(
1938 domain=provider_mapping.provider_domain,
1939 return_unavailable=True,
1940 )
1941 if len(prov_instances) > 1:
1942 # multiple provider instances for this domain exist
1943 # make sure the library item has all provider mappings
1944 for prov_instance in prov_instances:
1945 if not any(
1946 x.provider_instance == prov_instance.instance_id
1947 and x.item_id == provider_mapping.item_id
1948 for x in library_item.provider_mappings
1949 ):
1950 # missing provider mapping for another instance
1951 # the rest of the core logic will take care of adding it
1952 # just return False here to trigger that logic
1953 return False
1954
1955 # final check: availability
1956 return provider_mapping.available == library_mapping.available
1957 return False
1958