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