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