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