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