/
/
/
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, suppress
9from contextvars import ContextVar
10from dataclasses import dataclass, field
11from datetime import datetime
12from typing import TYPE_CHECKING, Final, cast
13
14from music_assistant_models.background_task import TaskSchedule
15from music_assistant_models.enums import ArtistType, MediaType, ProviderFeature
16from music_assistant_models.errors import (
17 AudioError,
18 InvalidDataError,
19 MediaNotFoundError,
20 MusicAssistantError,
21 UnsupportedFeaturedException,
22)
23from music_assistant_models.media_items import (
24 Album,
25 Artist,
26 Audiobook,
27 BrowseFolder,
28 ItemMapping,
29 MediaItemType,
30 Playlist,
31 Podcast,
32 PodcastEpisode,
33 Radio,
34 RecommendationFolder,
35 SearchResults,
36 SoundEffect,
37 Track,
38 UniqueList,
39)
40
41from music_assistant.constants import (
42 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
43 CONF_ENTRY_LIBRARY_SYNC_DELETIONS,
44 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS,
45 PlaylistPlayableItem,
46)
47from music_assistant.controllers.tasks.context import (
48 report_current_task_failure,
49 update_current_task_progress_text,
50)
51
52from .provider import Provider
53
54if TYPE_CHECKING:
55 from collections.abc import AsyncGenerator
56
57 from music_assistant_models.config_entries import ProviderConfig
58 from music_assistant_models.provider import ProviderManifest
59 from music_assistant_models.streamdetails import StreamDetails
60
61 from music_assistant.controllers.music.media.base import (
62 AudiobookSyncDetails,
63 LibraryItemSyncDetails,
64 TrackSyncDetails,
65 )
66 from music_assistant.mass import MusicAssistant
67
68CACHE_CATEGORY_PREV_LIBRARY_IDS: Final[int] = 1
69DEFAULT_MAX_CONCURRENT_STREAMS: Final[int] = 5
70# a provider-wide payload change fails every single item, so only the first failures
71# of a sync run are logged in full to keep the (rotating) log file usable
72MAX_LOGGED_SYNC_FAILURES: Final[int] = 25
73MAX_SYNC_ERROR_DETAIL: Final[int] = 200
74# skipped id's are resolved back to library id's in batches of this size
75SKIPPED_ITEM_QUERY_LIMIT: Final[int] = 500
76
77LIBRARY_FEATURE_BY_MEDIA_TYPE: Final[dict[MediaType, ProviderFeature]] = {
78 MediaType.ARTIST: ProviderFeature.LIBRARY_ARTISTS,
79 MediaType.ALBUM: ProviderFeature.LIBRARY_ALBUMS,
80 MediaType.TRACK: ProviderFeature.LIBRARY_TRACKS,
81 MediaType.PLAYLIST: ProviderFeature.LIBRARY_PLAYLISTS,
82 MediaType.RADIO: ProviderFeature.LIBRARY_RADIOS,
83 MediaType.AUDIOBOOK: ProviderFeature.LIBRARY_AUDIOBOOKS,
84 MediaType.PODCAST: ProviderFeature.LIBRARY_PODCASTS,
85}
86
87
88@dataclass
89class SyncRunState:
90 """
91 Failure state of one library sync run.
92
93 :param incomplete_media_types: Media types the run failed to collect an item for, which
94 makes their result set an unsafe basis for deleting anything from the library.
95 :param failures: Number of item failures reported by the run so far.
96 :param skipped_item_ids: Provider item id's the provider dropped while listing its
97 library, per media type.
98 """
99
100 incomplete_media_types: set[MediaType] = field(default_factory=set)
101 failures: int = 0
102 skipped_item_ids: dict[MediaType, set[str]] = field(default_factory=dict)
103
104
105# scoped per run rather than per provider: a standalone import_album_tracks() is
106# launched as its own task, so it must not consume or inflate a running sync's state
107SYNC_RUN_STATE: Final[ContextVar[SyncRunState | None]] = ContextVar(
108 "music_provider_sync_run", default=None
109)
110
111
112def sync_run_state() -> SyncRunState:
113 """Return the state of the sync run in progress, starting one if there is none."""
114 if (state := SYNC_RUN_STATE.get()) is None:
115 state = SyncRunState()
116 SYNC_RUN_STATE.set(state)
117 return state
118
119
120class ProviderStreamLimitError(AudioError):
121 """Raised when a music provider has no source-stream slot available."""
122
123 translation_key = "provider_stream_limit"
124
125 def __init__(self, provider: MusicProvider, wait_timeout: float | None) -> None:
126 """
127 Initialize the provider stream limit error.
128
129 :param provider: Provider instance whose source-stream limit was reached.
130 :param wait_timeout: Seconds spent waiting for a slot, or None for an unbounded wait.
131 """
132 limit = provider.max_concurrent_streams
133 assert limit is not None
134 wait_text = f" after waiting {wait_timeout:g} seconds" if wait_timeout is not None else ""
135 super().__init__(
136 f"{provider.name} has reached its limit of {limit} "
137 f"concurrent source streams{wait_text}.",
138 translation_args=[provider.name, limit],
139 )
140 self.provider_instance = provider.instance_id
141 self.limit = limit
142
143
144def describe_sync_error(err: Exception) -> str:
145 """Return a short description of a sync failure, safe to log and to report to clients."""
146 if isinstance(err, MusicAssistantError):
147 return str(err)
148 # an unexpected error can carry an entire api response as its message, which would end
149 # up in the log and - through the task failure list - in every connected client. report
150 # it by type with a clipped detail and leave the full payload to the debug traceback
151 detail = str(err)
152 if not detail:
153 return type(err).__name__
154 if len(detail) > MAX_SYNC_ERROR_DETAIL:
155 detail = f"{detail[:MAX_SYNC_ERROR_DETAIL]}..."
156 return f"{type(err).__name__}: {detail}"
157
158
159class MusicProvider(Provider):
160 """
161 Base representation of a Music Provider (controller).
162
163 Music Provider implementations should inherit from this base model.
164 """
165
166 def __init__(
167 self,
168 mass: MusicAssistant,
169 manifest: ProviderManifest,
170 config: ProviderConfig,
171 supported_features: set[ProviderFeature] | None = None,
172 ) -> None:
173 """Initialize MusicProvider."""
174 super().__init__(mass, manifest, config, supported_features)
175 max_concurrent_streams = self.max_concurrent_streams
176 if max_concurrent_streams is not None and max_concurrent_streams < 1:
177 raise ValueError("max_concurrent_streams must be at least 1 or None")
178 self._stream_semaphore = (
179 asyncio.BoundedSemaphore(max_concurrent_streams)
180 if max_concurrent_streams is not None
181 else None
182 )
183
184 def delivers_normalized_audio(self, streamdetails: StreamDetails) -> bool:
185 """
186 Return whether this provider hands over audio it has already normalized.
187
188 True means the source applies a loudness target of its own, so Music
189 Assistant leaves the level alone instead of measuring and correcting it
190 a second time. Only say so when the audio really is normalized on the
191 way out: nothing downstream double-checks it.
192
193 :param streamdetails: Stream details of the item being asked about. A
194 provider that normalizes per playback session answers for the queue
195 these details belong to, not for whatever it happens to serve
196 elsewhere.
197 """
198 return False
199
200 @property
201 def max_concurrent_streams(self) -> int | None:
202 """
203 Return the number of source streams Music Assistant may run against this provider.
204
205 None means no limit is imposed, which is the correct answer for local and
206 self-hosted sources. Streaming providers get a conservative default of five;
207 override with a lower, evidence-backed value where the service enforces one.
208 Plugin providers (exclusive audio sources) manage their own session exclusivity
209 and are not covered by this limit.
210 """
211 return DEFAULT_MAX_CONCURRENT_STREAMS if self.is_streaming_provider else None
212
213 @property
214 def has_available_stream_slot(self) -> bool:
215 """Return whether a source stream can start without waiting."""
216 return self._stream_semaphore is None or not self._stream_semaphore.locked()
217
218 @asynccontextmanager
219 async def acquire_stream_slot(self, wait_timeout: float | None) -> AsyncGenerator[None]:
220 """
221 Acquire one source-stream slot for the duration of the context.
222
223 :param wait_timeout: Maximum seconds to wait, or None to wait without a timeout.
224 :raises ProviderStreamLimitError: If no slot becomes available before the timeout.
225 """
226 semaphore = self._stream_semaphore
227 if semaphore is None:
228 yield
229 return
230 try:
231 if wait_timeout is None:
232 await semaphore.acquire()
233 else:
234 async with asyncio.timeout(wait_timeout):
235 await semaphore.acquire()
236 except TimeoutError as err:
237 raise ProviderStreamLimitError(self, wait_timeout) from err
238 try:
239 yield
240 finally:
241 semaphore.release()
242
243 @property
244 def is_streaming_provider(self) -> bool:
245 """
246 Return True if the provider is a streaming provider.
247
248 This literally means that the catalog is not the same as the library contents.
249 For local based providers (files, plex), the catalog is the same as the library content.
250 It also means that data is if this provider is NOT a streaming provider,
251 data cross instances is unique, the catalog and library differs per instance.
252
253 Setting this to True will only query one instance of the provider for search and lookups.
254 Setting this to False will query all instances of this provider for search and lookups.
255 """
256 return True
257
258 @property
259 def supported_media_types(self) -> set[MediaType]:
260 """
261 Return the media types this provider can serve.
262
263 Defaults to the media types the provider declares library support for.
264 Override for providers that can serve (search/stream) media types they
265 cannot list as library items, so they are eligible for search-based
266 lookups such as cross-provider matching and versions.
267 """
268 return {
269 media_type
270 for media_type, feature in LIBRARY_FEATURE_BY_MEDIA_TYPE.items()
271 if feature in self.supported_features
272 }
273
274 @property
275 def unskippable_sync_errors(self) -> tuple[type[Exception], ...]:
276 """
277 Return the errors a library sync must never treat as a skippable item failure.
278
279 Declare the errors this provider raises to signal something a wrapper around its
280 own methods has to act on, such as an expired token that triggers a reauthenticate
281 and a retry. Anything listed here is re-raised instead of skipping the item.
282 """
283 return ()
284
285 @property
286 def supported_artist_types(self) -> set[ArtistType]:
287 """
288 Return all supported artist types by this provider.
289
290 Note, that this property currently is only used, to verify support of artists with
291 ArtistType.AUTHOR or ArtistType.NARRATOR.
292 """
293 return {ArtistType.SINGER}
294
295 async def loaded_in_mass(self) -> None:
296 """Call after the provider has been loaded."""
297
298 async def search(
299 self,
300 search_query: str,
301 media_types: list[MediaType],
302 limit: int = 5,
303 ) -> SearchResults:
304 """
305 Perform search on musicprovider.
306
307 :param search_query: Search query.
308 :param media_types: A list of media_types to include.
309 :param limit: Number of items to return in the search (per type).
310 """
311 if ProviderFeature.SEARCH in self.supported_features:
312 raise NotImplementedError
313 return SearchResults()
314
315 async def get_library_artists(self) -> AsyncGenerator[Artist]:
316 """Retrieve library artists from the provider."""
317 yield # type: ignore[misc]
318 raise NotImplementedError
319
320 async def get_library_albums(self) -> AsyncGenerator[Album]:
321 """Retrieve library albums from the provider."""
322 yield # type: ignore[misc]
323 raise NotImplementedError
324
325 async def get_library_tracks(self) -> AsyncGenerator[Track]:
326 """Retrieve library tracks from the provider."""
327 yield # type: ignore[misc]
328 raise NotImplementedError
329
330 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
331 """Retrieve library/subscribed playlists from the provider."""
332 yield # type: ignore[misc]
333 raise NotImplementedError
334
335 async def get_library_radios(self) -> AsyncGenerator[Radio]:
336 """Retrieve library/subscribed radio stations from the provider."""
337 yield # type: ignore[misc]
338 raise NotImplementedError
339
340 async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook]:
341 """Retrieve library/subscribed audiobooks from the provider."""
342 yield # type: ignore[misc]
343 raise NotImplementedError
344
345 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
346 """Retrieve library/subscribed podcasts from the provider."""
347 yield # type: ignore[misc]
348 raise NotImplementedError
349
350 async def get_library_genres(self) -> AsyncGenerator[str]:
351 """Retrieve library genres from the provider."""
352 yield # type: ignore[misc]
353 raise NotImplementedError
354
355 async def get_artist(self, prov_artist_id: str) -> Artist:
356 """Get full artist details by id."""
357 raise NotImplementedError
358
359 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
360 """
361 Get a list of all albums for the given artist.
362
363 Only called if provider supports ProviderFeature.ARTIST_ALBUMS.
364 """
365 raise NotImplementedError
366
367 async def get_artist_tracks(self, prov_artist_id: str) -> list[Track]:
368 """
369 Get a list of all tracks for the given artist.
370
371 Only called if provider supports ProviderFeature.ARTIST_TRACKS.
372 """
373 raise NotImplementedError
374
375 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
376 """
377 Get a list of most popular tracks for the given artist.
378
379 Only called if provider supports ProviderFeature.ARTIST_TOPTRACKS.
380 """
381 raise NotImplementedError
382
383 async def get_artist_topalbums(self, prov_artist_id: str) -> list[Album]:
384 """
385 Get a list of most popular albums for the given artist.
386
387 Only called if provider supports ProviderFeature.ARTIST_TOPALBUMS.
388 """
389 raise NotImplementedError
390
391 async def get_album(self, prov_album_id: str) -> Album:
392 """Get full album details by id."""
393 raise NotImplementedError
394
395 async def get_track(self, prov_track_id: str) -> Track:
396 """Get full track details by id."""
397 raise NotImplementedError
398
399 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
400 """Get full playlist details by id."""
401 raise NotImplementedError
402
403 async def get_radio(self, prov_radio_id: str) -> Radio:
404 """Get full radio details by id."""
405 raise NotImplementedError
406
407 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
408 """Get full audiobook details by id."""
409 raise NotImplementedError
410
411 async def get_author_audiobooks(self, prov_artist_id: str) -> list[Audiobook]:
412 """
413 Get a list of all audiobooks for the given author.
414
415 Only called if provider supports ProviderFeature.AUTHOR_AUDIOBOOKS.
416 """
417 raise NotImplementedError
418
419 async def get_narrator_audiobooks(self, prov_artist_id: str) -> list[Audiobook]:
420 """
421 Get a list of all audiobooks for the given narrator.
422
423 Only called if provider supports ProviderFeature.NARRATOR_AUDIOBOOKS.
424 """
425 raise NotImplementedError
426
427 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
428 """Get full podcast details by id."""
429 raise NotImplementedError
430
431 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
432 """Get (full) podcast episode details by id."""
433 raise NotImplementedError
434
435 async def get_sound_effect(self, prov_sound_effect_id: str) -> SoundEffect:
436 """Get full sound effect details by id."""
437 raise NotImplementedError
438
439 async def get_sound_effects(self) -> AsyncGenerator[SoundEffect]:
440 """
441 Get all sound effect items this provider offers.
442
443 Sound effects are not library-backed; they are fetched live from the provider.
444 Only called if provider supports ProviderFeature.SOUND_EFFECTS.
445 """
446 yield # type: ignore[misc]
447 raise NotImplementedError
448
449 async def get_item_genre_names(self, media_type: MediaType, item_id: str) -> set[str]:
450 """Return genre names for a single item."""
451 raise NotImplementedError
452
453 async def get_album_tracks(
454 self,
455 prov_album_id: str,
456 ) -> list[Track]:
457 """Get album tracks for given album id."""
458 raise NotImplementedError
459
460 async def get_playlist_tracks(
461 self,
462 prov_playlist_id: str,
463 page: int = 0,
464 ) -> Sequence[PlaylistPlayableItem]:
465 """Get all playlist tracks for given playlist id."""
466 raise NotImplementedError
467
468 async def get_dynamic_radio_tracks(self, prov_radio_id: str) -> list[Track]:
469 """
470 Return a fresh batch of tracks for a dynamic radio station.
471
472 Only called for a Radio with `is_dynamic` set. Every call returns a new batch;
473 there is no stable listing and no pagination.
474
475 :param prov_radio_id: The provider's ID of the radio station.
476 """
477 raise NotImplementedError
478
479 async def get_podcast_episodes(
480 self,
481 prov_podcast_id: str,
482 ) -> AsyncGenerator[PodcastEpisode]:
483 """Get all PodcastEpisodes for given podcast id."""
484 yield # type: ignore[misc]
485 raise NotImplementedError
486
487 async def library_add(self, item: MediaItemType) -> bool:
488 """Add item to provider's library. Return true on success."""
489 if (
490 item.media_type == MediaType.ARTIST
491 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
492 ):
493 raise NotImplementedError
494 if (
495 item.media_type == MediaType.ALBUM
496 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
497 ):
498 raise NotImplementedError
499 if (
500 item.media_type == MediaType.TRACK
501 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
502 ):
503 raise NotImplementedError
504 if (
505 item.media_type == MediaType.PLAYLIST
506 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
507 ):
508 raise NotImplementedError
509 if (
510 item.media_type == MediaType.RADIO
511 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
512 ):
513 raise NotImplementedError
514 if (
515 item.media_type == MediaType.AUDIOBOOK
516 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
517 ):
518 raise NotImplementedError
519 if (
520 item.media_type == MediaType.PODCAST
521 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
522 ):
523 raise NotImplementedError
524 self.logger.info(
525 "Provider %s does not support library edit, "
526 "the action will only be performed in the local database.",
527 self.name,
528 )
529 return True
530
531 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
532 """Remove item from provider's library. Return true on success."""
533 if (
534 media_type == MediaType.ARTIST
535 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
536 ):
537 raise NotImplementedError
538 if (
539 media_type == MediaType.ALBUM
540 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
541 ):
542 raise NotImplementedError
543 if (
544 media_type == MediaType.TRACK
545 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
546 ):
547 raise NotImplementedError
548 if (
549 media_type == MediaType.PLAYLIST
550 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
551 ):
552 raise NotImplementedError
553 if (
554 media_type == MediaType.RADIO
555 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
556 ):
557 raise NotImplementedError
558 if (
559 media_type == MediaType.AUDIOBOOK
560 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
561 ):
562 raise NotImplementedError
563 if (
564 media_type == MediaType.PODCAST
565 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
566 ):
567 raise NotImplementedError
568 self.logger.info(
569 "Provider %s does not support library edit, "
570 "the action will only be performed in the local database.",
571 self.name,
572 )
573 return True
574
575 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
576 """
577 Set favorite status for item in provider's library.
578
579 Only called if provider supports ProviderFeature.FAVORITE_*_EDIT.
580
581 Note that this should only be implemented by a provider implementation if
582 the provider differentiates between 'in library' and 'favorited' items.
583 """
584 if (
585 media_type == MediaType.ARTIST
586 and ProviderFeature.FAVORITE_ARTISTS_EDIT in self.supported_features
587 ):
588 raise NotImplementedError
589 if (
590 media_type == MediaType.ALBUM
591 and ProviderFeature.FAVORITE_ALBUMS_EDIT in self.supported_features
592 ):
593 raise NotImplementedError
594 if (
595 media_type == MediaType.TRACK
596 and ProviderFeature.FAVORITE_TRACKS_EDIT in self.supported_features
597 ):
598 raise NotImplementedError
599 if (
600 media_type == MediaType.PLAYLIST
601 and ProviderFeature.FAVORITE_PLAYLISTS_EDIT in self.supported_features
602 ):
603 raise NotImplementedError
604 if (
605 media_type == MediaType.RADIO
606 and ProviderFeature.FAVORITE_RADIOS_EDIT in self.supported_features
607 ):
608 raise NotImplementedError
609 if (
610 media_type == MediaType.AUDIOBOOK
611 and ProviderFeature.FAVORITE_AUDIOBOOKS_EDIT in self.supported_features
612 ):
613 raise NotImplementedError
614 if (
615 media_type == MediaType.PODCAST
616 and ProviderFeature.FAVORITE_PODCASTS_EDIT in self.supported_features
617 ):
618 raise NotImplementedError
619
620 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
621 """
622 Add track(s) to playlist.
623
624 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
625 """
626 raise NotImplementedError
627
628 async def remove_playlist_tracks(
629 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
630 ) -> None:
631 """
632 Remove track(s) from playlist.
633
634 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
635 """
636 raise NotImplementedError
637
638 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
639 """
640 Create a new playlist on provider with given name and targeting media_types.
641
642 Only called if provider supports ProviderFeature.PLAYLIST_CREATE.
643 """
644 raise NotImplementedError
645
646 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
647 """
648 Retrieve a dynamic list of similar tracks based on the provided track.
649
650 Only called if provider supports ProviderFeature.SIMILAR_TRACKS.
651 """
652 raise NotImplementedError
653
654 async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]:
655 """
656 Retrieve a dynamic list of similar artists based on the provided artist.
657
658 Only called if provider supports ProviderFeature.SIMILAR_ARTISTS.
659 """
660 raise NotImplementedError
661
662 async def get_resume_position(
663 self, item_id: str, media_type: MediaType
664 ) -> tuple[bool, int, datetime | None]:
665 """
666 Get progress (resume point) details for the given Audiobook or Podcast episode.
667
668 This is a separate call from the regular get_item call to ensure the resume position
669 is always up-to-date and because a lot providers have this info present on a dedicated
670 endpoint.
671
672 Will be called right before playback starts to ensure the resume position is correct.
673
674 Returns a boolean with the fully_played status
675 an integer with the resume position in ms,
676 and an optional timestamp as datetime giving when this resume position was set
677 """
678 raise NotImplementedError
679
680 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
681 """Get streamdetails for a track/radio/chapter/episode."""
682 raise NotImplementedError
683
684 async def get_audio_stream(
685 self, streamdetails: StreamDetails, seek_position: int = 0
686 ) -> AsyncGenerator[bytes]:
687 """
688 Return the (custom) audio stream for the provider item.
689
690 Will only be called when the stream_type is set to CUSTOM.
691 """
692 yield b""
693 raise NotImplementedError
694
695 async def on_streamed(
696 self,
697 streamdetails: StreamDetails,
698 ) -> None:
699 """
700 Handle callback when given streamdetails completed streaming.
701
702 To get the number of seconds streamed, see streamdetails.seconds_streamed.
703 To get the number of seconds seeked/skipped, see streamdetails.seek_position.
704 Note that seconds_streamed is the total streamed seconds, so without seeked time.
705
706 NOTE: Due to internal and player buffering,
707 this may be called in advance of the actual completion.
708 """
709
710 async def on_played(
711 self,
712 media_type: MediaType,
713 prov_item_id: str,
714 fully_played: bool,
715 position: int,
716 media_item: MediaItemType,
717 is_playing: bool = False,
718 ) -> None:
719 """
720 Handle callback when a (playable) media item has been played.
721
722 This is called by the Queue controller when;
723 - a track has been fully played
724 - a track has been stopped (or skipped) after being played
725 - every 30s when a track is playing
726
727 Fully played is True when the track has been played to the end.
728
729 Position is the last known position of the track in seconds, to sync resume state.
730 When fully_played is set to false and position is 0,
731 the user marked the item as unplayed in the UI.
732
733 media_item is the full media item details of the played/playing track.
734
735 is_playing is True when the track is currently playing.
736 """
737
738 async def on_item_updated(self, item: MediaItemType) -> None:
739 """
740 Handle callback when a library item's metadata has been updated.
741
742 Providers can implement this to sync changes to their own storage
743 (e.g. config entries, file tags).
744
745 :param item: The updated library item.
746 """
747
748 async def resolve_image(self, path: str) -> str | bytes:
749 """
750 Resolve an image from an image path.
751
752 This either returns (a generator to get) raw bytes of the image or
753 a string with an http(s) URL or local path that is accessible from the server.
754 """
755 return path
756
757 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: # noqa: PLR0911
758 """
759 Browse this provider's items.
760
761 :param path: The path to browse, (e.g. provider_id://artists).
762 """
763 if ProviderFeature.BROWSE not in self.supported_features:
764 # we may NOT use the default implementation if the provider does not support browse
765 raise NotImplementedError
766
767 path_parts = path.split("://")[1].split("/")
768 subpath = path_parts[0] if len(path_parts) > 0 else None
769 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
770 # this reference implementation can be overridden with a provider specific approach
771 if subpath == "artists":
772 if artists := await self.mass.music.artists.library_items(
773 provider=self.instance_id,
774 summary=False,
775 ):
776 return artists
777 # library items not (yet) synced, fallback to direct retrieval
778 return [x async for x in self.get_library_artists()]
779 if subpath == "albums":
780 if albums := await self.mass.music.albums.library_items(
781 provider=self.instance_id,
782 summary=False,
783 ):
784 return albums
785 # library items not (yet) synced, fallback to direct retrieval
786 return [x async for x in self.get_library_albums()]
787 if subpath == "tracks":
788 if tracks := await self.mass.music.tracks.library_items(
789 provider=self.instance_id,
790 summary=False,
791 ):
792 return tracks
793 # library items not (yet) synced, fallback to direct retrieval
794 return [x async for x in self.get_library_tracks()]
795 if subpath == "radios":
796 if radios := await self.mass.music.radio.library_items(
797 provider=self.instance_id,
798 summary=False,
799 ):
800 return radios
801 # library items not (yet) synced, fallback to direct retrieval
802 return [x async for x in self.get_library_radios()]
803 if subpath == "playlists":
804 if playlists := await self.mass.music.playlists.library_items(
805 provider=self.instance_id,
806 summary=False,
807 ):
808 return playlists
809 # library items not (yet) synced, fallback to direct retrieval
810 return [x async for x in self.get_library_playlists()]
811 if subpath == "audiobooks":
812 if audiobooks := await self.mass.music.audiobooks.library_items(
813 provider=self.instance_id,
814 summary=False,
815 ):
816 return audiobooks
817 # library items not (yet) synced, fallback to direct retrieval
818 return [x async for x in self.get_library_audiobooks()]
819 if subpath == "podcasts":
820 if podcasts := await self.mass.music.podcasts.library_items(
821 provider=self.instance_id,
822 summary=False,
823 ):
824 return podcasts
825 # library items not (yet) synced, fallback to direct retrieval
826 return [x async for x in self.get_library_podcasts()]
827 if subpath == "sound_effects":
828 # sound effects are not library-backed, always retrieve them live
829 return [x async for x in self.get_sound_effects()]
830 if subpath == "recommendations" and sub_subpath:
831 # recommendations contents listing
832 return await self.get_recommendation_items(sub_subpath)
833 if subpath == "recommendations":
834 # Main recommendations listing
835 result: list[BrowseFolder] = []
836 recommendations = await self.get_recommendations()
837 for rec in recommendations:
838 result.append(
839 BrowseFolder(
840 item_id=rec.item_id,
841 provider=self.instance_id,
842 name=rec.name,
843 is_playable=rec.is_playable,
844 image=rec.image,
845 path=f"{path}/{rec.item_id}",
846 )
847 )
848 return result
849
850 if subpath:
851 # unknown path
852 msg = "Invalid subpath"
853 raise KeyError(msg)
854
855 # no subpath: return main listing
856 folders: list[BrowseFolder] = []
857 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
858 folders.append(
859 BrowseFolder(
860 item_id="artists",
861 provider=self.instance_id,
862 path=path + "artists",
863 name="",
864 translation_key="artists",
865 is_playable=True,
866 )
867 )
868 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
869 folders.append(
870 BrowseFolder(
871 item_id="albums",
872 provider=self.instance_id,
873 path=path + "albums",
874 name="",
875 translation_key="albums",
876 is_playable=True,
877 )
878 )
879 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
880 folders.append(
881 BrowseFolder(
882 item_id="tracks",
883 provider=self.domain,
884 path=path + "tracks",
885 name="",
886 translation_key="tracks",
887 is_playable=True,
888 )
889 )
890 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
891 folders.append(
892 BrowseFolder(
893 item_id="playlists",
894 provider=self.instance_id,
895 path=path + "playlists",
896 name="",
897 translation_key="playlists",
898 is_playable=True,
899 )
900 )
901 if ProviderFeature.LIBRARY_RADIOS in self.supported_features:
902 folders.append(
903 BrowseFolder(
904 item_id="radios",
905 provider=self.instance_id,
906 path=path + "radios",
907 name="",
908 translation_key="radios",
909 )
910 )
911 if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
912 folders.append(
913 BrowseFolder(
914 item_id="audiobooks",
915 provider=self.instance_id,
916 path=path + "audiobooks",
917 name="",
918 translation_key="audiobooks",
919 )
920 )
921 if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
922 folders.append(
923 BrowseFolder(
924 item_id="podcasts",
925 provider=self.instance_id,
926 path=path + "podcasts",
927 name="",
928 translation_key="podcasts",
929 )
930 )
931 if ProviderFeature.SOUND_EFFECTS in self.supported_features:
932 folders.append(
933 BrowseFolder(
934 item_id="sound_effects",
935 provider=self.instance_id,
936 path=path + "sound_effects",
937 name="",
938 translation_key="sound_effects",
939 )
940 )
941 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
942 folders.append(
943 BrowseFolder(
944 item_id="recommendations",
945 provider=self.instance_id,
946 path=path + "recommendations",
947 name="",
948 translation_key="recommendations",
949 )
950 )
951 if len(folders) == 1:
952 # only one level, return the items directly
953 return await self.browse(folders[0].path)
954 return folders
955
956 async def get_recommendations(self) -> list[RecommendationFolder]:
957 """
958 Get this provider's available recommendation rows, without items.
959
960 Must be fast: return static or cached row descriptors only, without
961 live backend calls. The items for a row are fetched separately
962 through get_recommendation_items.
963
964 Will only be called if ProviderFeature.RECOMMENDATIONS is declared.
965 """
966 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
967 raise NotImplementedError
968 return []
969
970 async def get_recommendation_items(
971 self, item_id: str
972 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
973 """
974 Get the items for a single recommendation row.
975
976 Live backend fetches belong here. Will only be called if
977 ProviderFeature.RECOMMENDATIONS is declared.
978
979 :param item_id: The item_id of the row, as returned by get_recommendations.
980 """
981 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
982 raise NotImplementedError
983 return UniqueList()
984
985 async def sync_library(self, media_type: MediaType) -> None:
986 """Run library sync for this provider."""
987 token = SYNC_RUN_STATE.set(SyncRunState())
988 try:
989 await self._run_library_sync(media_type)
990 finally:
991 SYNC_RUN_STATE.reset(token)
992
993 def report_skipped_sync_item(
994 self, media_type: MediaType, item_id: str | None, err: Exception
995 ) -> None:
996 """
997 Report a library item that was dropped while listing this provider's library.
998
999 Call this from a get_library_*() generator whenever it swallows an error instead of
1000 yielding the item, so the failure is reported on the sync task rather than the item
1001 looking like it was removed at the provider.
1002
1003 :param media_type: Media type of the skipped item.
1004 :param item_id: The provider item id of the skipped item, which keeps that single item
1005 out of this sync's deletion pass. Pass None if the item cannot be identified, which
1006 holds back the deletion pass for the entire run instead.
1007 :param err: The error that made the item unusable.
1008 :raises Exception: If this provider declared the error unskippable, so that its own
1009 error handling can act on it instead of the item being skipped.
1010 """
1011 self._handle_sync_item_failure(media_type, item_id, err)
1012 state = sync_run_state()
1013 if item_id:
1014 state.skipped_item_ids.setdefault(media_type, set()).add(item_id)
1015 else:
1016 state.incomplete_media_types.add(media_type)
1017
1018 async def _run_library_sync(self, media_type: MediaType) -> None:
1019 """Sync the given media type into the library and process its deletions."""
1020 # this reference implementation may be overridden
1021 # with a provider specific approach if needed
1022
1023 if not self.mass.music.library_supported(self, media_type):
1024 raise UnsupportedFeaturedException("Library sync not supported for this media type")
1025
1026 sync_state = sync_run_state()
1027 if media_type == MediaType.ARTIST:
1028 cur_db_ids = await self._sync_library_artists()
1029 elif media_type == MediaType.ALBUM:
1030 cur_db_ids = await self._sync_library_albums()
1031 elif media_type == MediaType.TRACK:
1032 cur_db_ids = await self._sync_library_tracks()
1033 elif media_type == MediaType.PLAYLIST:
1034 cur_db_ids = await self._sync_library_playlists()
1035 elif media_type == MediaType.PODCAST:
1036 cur_db_ids = await self._sync_library_podcasts()
1037 elif media_type == MediaType.RADIO:
1038 cur_db_ids = await self._sync_library_radios()
1039 elif media_type == MediaType.AUDIOBOOK:
1040 cur_db_ids = await self._sync_library_audiobooks()
1041 else:
1042 # this should not happen but catch it anyways
1043 raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}")
1044
1045 # process deletions (= no longer in library)
1046 update_current_task_progress_text("Checking library deletions")
1047 controller = self.mass.music.get_controller(media_type)
1048 await self._keep_skipped_items(media_type, cur_db_ids)
1049 prev_library_items: list[int] | None
1050 if media_type in sync_state.incomplete_media_types:
1051 # a skipped item is missing from cur_db_ids just like a deleted one, but it is
1052 # still in the provider's library, so deleting it would throw away valid content
1053 if self.library_sync_deletions_enabled():
1054 summary = f"{sync_state.failures} item(s) could not be synced"
1055 self.logger.warning("Skipping deletions for %s: %s", self.name, summary)
1056 report_current_task_failure(f"Deletions skipped: {summary}")
1057 # merge this run's id's into the stored ones instead of replacing them: that
1058 # keeps both the deletions this run could not tell apart from its own failures
1059 # and the items it saw for the first time, so a later complete run finds either
1060 if prev_library_items := await self.mass.cache.get(
1061 key=media_type.value,
1062 provider=self.instance_id,
1063 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1064 ):
1065 cur_db_ids.update(prev_library_items)
1066 elif self.library_sync_deletions_enabled():
1067 if prev_library_items := await self.mass.cache.get(
1068 key=media_type.value,
1069 provider=self.instance_id,
1070 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1071 ):
1072 for db_id in prev_library_items:
1073 if db_id not in cur_db_ids:
1074 try:
1075 library_item = await controller.get_library_item(db_id)
1076 except MediaNotFoundError:
1077 # edge case: the item is (already) removed from MA library as well
1078 continue
1079 # check if we have other provider-mappings (marked as in-library)
1080 remaining_providers_in_library = {
1081 x.provider_instance
1082 for x in library_item.provider_mappings
1083 if x.provider_instance != self.instance_id and x.in_library
1084 }
1085 if not remaining_providers_in_library and not self.is_streaming_provider:
1086 # for non-streaming providers (local files, library-middlemen
1087 # like subsonic/jellyfin/plex) an item removed from the provider
1088 # is actually gone; fully remove it to avoid dangling records
1089 # that stay visible in artist/album views where in_library is
1090 # not filtered on
1091 await controller.remove_item_from_library(db_id)
1092 else:
1093 if not remaining_providers_in_library and library_item.favorite:
1094 # unmark as favorite since no providers have it in library
1095 await controller.set_favorite(db_id, False)
1096 # unmark this provider mapping as in_library = False
1097 # we keep it in the library database so we can keep the metadata
1098 for prov_map in library_item.provider_mappings:
1099 if prov_map.provider_instance == self.instance_id:
1100 prov_map.in_library = False
1101 await controller.set_provider_mappings(
1102 db_id, library_item.provider_mappings
1103 )
1104 await asyncio.sleep(0) # yield to eventloop
1105 # store current list of id's in cache so we can track changes
1106 await self.mass.cache.set(
1107 key=media_type.value,
1108 data=list(cur_db_ids),
1109 provider=self.instance_id,
1110 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
1111 )
1112 update_current_task_progress_text("Finalizing library sync")
1113
1114 def _update_sync_task_item_status(
1115 self, media_type: MediaType, processed_items: int, item_name: str | None = None
1116 ) -> None:
1117 """Update task text for the item currently being synced."""
1118 message = f"Processed {processed_items} {media_type.value}s"
1119 if item_name:
1120 message = f"{message}: {item_name}"
1121 update_current_task_progress_text(message)
1122
1123 def _handle_sync_item_failure(
1124 self, media_type: MediaType, item_ref: str | None, err: Exception
1125 ) -> None:
1126 """
1127 Log a non-fatal sync failure and record it on the active background task.
1128
1129 :raises Exception: If the provider declared this error unskippable, so that its own
1130 error handling can act on it instead of the item being skipped.
1131 """
1132 if isinstance(err, self.unskippable_sync_errors):
1133 raise err
1134 state = sync_run_state()
1135 state.failures += 1
1136 error_detail = describe_sync_error(err)
1137 if state.failures <= MAX_LOGGED_SYNC_FAILURES:
1138 if isinstance(err, MusicAssistantError):
1139 self.logger.warning(
1140 "Skipping sync of %s %s - error details: %s",
1141 media_type.value,
1142 item_ref,
1143 error_detail,
1144 )
1145 else:
1146 # not one of our own errors: usually a provider choking on its own api
1147 # payload, but the per-item library writes raise the same way, so log the
1148 # traceback (on debug) to make the actual origin traceable
1149 self.logger.error(
1150 "Skipping sync of %s %s - unexpected error: %s",
1151 media_type.value,
1152 item_ref,
1153 error_detail,
1154 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
1155 )
1156 report_current_task_failure(
1157 f"Failed to sync {media_type.value} {item_ref or '<unknown>'}: {error_detail}"
1158 )
1159
1160 async def _keep_skipped_items(self, media_type: MediaType, cur_db_ids: set[int]) -> None:
1161 """
1162 Add the library id's of the items the provider skipped to this run's result set.
1163
1164 A skipped item is still in the provider's library, so leaving it out would let the
1165 deletion pass read it as removed.
1166 """
1167 if not (skipped_item_ids := sorted(sync_run_state().skipped_item_ids.get(media_type, ()))):
1168 return
1169 controller = self.mass.music.get_controller(media_type)
1170 for index in range(0, len(skipped_item_ids), SKIPPED_ITEM_QUERY_LIMIT):
1171 for library_item in await controller.get_library_items_by_prov_id(
1172 provider_instance=self.instance_id,
1173 provider_item_ids=skipped_item_ids[index : index + SKIPPED_ITEM_QUERY_LIMIT],
1174 limit=SKIPPED_ITEM_QUERY_LIMIT,
1175 ):
1176 cur_db_ids.add(int(library_item.item_id))
1177
1178 def _protect_failed_sync_item(
1179 self,
1180 media_type: MediaType,
1181 provider_item_id: str | None,
1182 library_item_id: int | None,
1183 cur_db_ids: set[int],
1184 ) -> None:
1185 """Keep a failed item out of this run's deletion pass."""
1186 if library_item_id is not None:
1187 cur_db_ids.add(library_item_id)
1188 elif provider_item_id:
1189 sync_run_state().skipped_item_ids.setdefault(media_type, set()).add(provider_item_id)
1190 else:
1191 sync_run_state().incomplete_media_types.add(media_type)
1192
1193 async def _sync_item_genres(
1194 self,
1195 media_type: MediaType,
1196 provider_item_id: str,
1197 library_item_id: int,
1198 fallback_genres: set[str] | None = None,
1199 ) -> None:
1200 try:
1201 genre_names = await self.get_item_genre_names(media_type, provider_item_id)
1202 except NotImplementedError:
1203 if fallback_genres is None:
1204 return
1205 genre_names = fallback_genres
1206
1207 await self.mass.music.genres.sync_media_item_genres(
1208 media_type, library_item_id, set(genre_names)
1209 )
1210
1211 async def _sync_library_artists(self) -> set[int]:
1212 """Sync Library Artists to Music Assistant library."""
1213 self.logger.debug("Start sync of Artists to Music Assistant library.")
1214 cur_db_ids: set[int] = set()
1215 item_count = 0
1216 async for prov_item in self.get_library_artists():
1217 item_count += 1
1218 self._update_sync_task_item_status(MediaType.ARTIST, item_count, prov_item.name)
1219 db_id: int | None = None
1220 try:
1221 sync_details = await self.mass.music.artists.get_library_item_sync_details(
1222 prov_item.provider_mappings,
1223 )
1224 db_id = sync_details.item_id if sync_details else None
1225 # batch all writes for this item into a single commit
1226 async with self.mass.music.database.deferred_commit():
1227 if not sync_details:
1228 # add item to the library
1229 for prov_map in prov_item.provider_mappings:
1230 prov_map.in_library = True
1231 library_item = await self.mass.music.artists.add_item_to_library(prov_item)
1232 db_id = int(library_item.item_id)
1233 favorite = library_item.favorite
1234 elif self._library_item_needs_update(sync_details, prov_item):
1235 library_item = await self.mass.music.artists.update_item_in_library(
1236 sync_details.item_id, prov_item
1237 )
1238 db_id = int(library_item.item_id)
1239 favorite = library_item.favorite
1240 else:
1241 db_id = sync_details.item_id
1242 favorite = sync_details.favorite
1243 cur_db_ids.add(db_id)
1244 if not favorite and prov_item.favorite:
1245 # existing library item not favorite but should be
1246 await self.mass.music.artists.set_favorite(db_id, True)
1247 fallback_genres = (
1248 set(prov_item.metadata.genres)
1249 if prov_item.metadata and prov_item.metadata.genres
1250 else None
1251 )
1252 await self._sync_item_genres(
1253 MediaType.ARTIST,
1254 prov_item.item_id,
1255 db_id,
1256 fallback_genres,
1257 )
1258 await asyncio.sleep(0) # yield to eventloop
1259 except Exception as err:
1260 self._handle_sync_item_failure(MediaType.ARTIST, prov_item.uri, err)
1261 self._protect_failed_sync_item(
1262 MediaType.ARTIST, prov_item.item_id, db_id, cur_db_ids
1263 )
1264 return cur_db_ids
1265
1266 def library_sync_album_tracks_enabled(self) -> bool:
1267 """Return whether all tracks of an album should be imported into the library."""
1268 return bool(
1269 self.config.get_value(
1270 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.key,
1271 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.default_value,
1272 )
1273 )
1274
1275 async def _sync_library_albums(self) -> set[int]:
1276 """Sync Library Albums to Music Assistant library."""
1277 self.logger.debug("Start sync of Albums to Music Assistant library.")
1278 cur_db_ids: set[int] = set()
1279 sync_album_tracks = self.library_sync_album_tracks_enabled()
1280 item_count = 0
1281 async for prov_item in self.get_library_albums():
1282 item_count += 1
1283 self._update_sync_task_item_status(MediaType.ALBUM, item_count, prov_item.name)
1284 db_id: int | None = None
1285 try:
1286 sync_details = await self.mass.music.albums.get_library_item_sync_details(
1287 prov_item.provider_mappings,
1288 )
1289 db_id = sync_details.item_id if sync_details else None
1290 # batch all writes for this item into a single commit
1291 async with self.mass.music.database.deferred_commit():
1292 if not sync_details:
1293 # add item to the library
1294 for prov_map in prov_item.provider_mappings:
1295 prov_map.in_library = True
1296 library_item = await self.mass.music.albums.add_item_to_library(prov_item)
1297 db_id = int(library_item.item_id)
1298 favorite = library_item.favorite
1299 elif self._library_item_needs_update(sync_details, prov_item):
1300 library_item = await self.mass.music.albums.update_item_in_library(
1301 sync_details.item_id, prov_item
1302 )
1303 db_id = int(library_item.item_id)
1304 favorite = library_item.favorite
1305 else:
1306 db_id = sync_details.item_id
1307 favorite = sync_details.favorite
1308 cur_db_ids.add(db_id)
1309 if not favorite and prov_item.favorite:
1310 # existing library item not favorite but should be
1311 await self.mass.music.albums.set_favorite(db_id, True)
1312 fallback_genres = (
1313 set(prov_item.metadata.genres)
1314 if prov_item.metadata and prov_item.metadata.genres
1315 else None
1316 )
1317 await self._sync_item_genres(
1318 MediaType.ALBUM,
1319 prov_item.item_id,
1320 db_id,
1321 fallback_genres,
1322 )
1323 await asyncio.sleep(0) # yield to eventloop
1324 except Exception as err:
1325 self._handle_sync_item_failure(MediaType.ALBUM, prov_item.uri, err)
1326 self._protect_failed_sync_item(
1327 MediaType.ALBUM, prov_item.item_id, db_id, cur_db_ids
1328 )
1329 continue
1330 # optionally add album tracks to library. the album is already collected here,
1331 # so failing to import its tracks does not make the album result set incomplete
1332 if sync_album_tracks:
1333 try:
1334 await self.import_album_tracks(prov_item.item_id, prov_item)
1335 except Exception as err:
1336 self._handle_sync_item_failure(MediaType.ALBUM, prov_item.uri, err)
1337 return cur_db_ids
1338
1339 async def import_album_tracks(self, prov_album_id: str, album: Album | None = None) -> None:
1340 """
1341 Import all tracks of the given (provider) album into the Music Assistant library.
1342
1343 :param prov_album_id: The provider item id of the album.
1344 :param album: The album the tracks belong to.
1345 Fetched from the provider when not given.
1346 """
1347 self.logger.debug(
1348 "Importing Album Tracks into the Music Assistant library for album %s.",
1349 album.name if album else prov_album_id,
1350 )
1351 prov_tracks = await self.get_album_tracks(prov_album_id)
1352 # some providers leave the (redundant) album off the tracks in an album listing.
1353 # without it the track is stored unfiled, so resolve it once for the whole import.
1354 if album is None and any(prov_track.album is None for prov_track in prov_tracks):
1355 with suppress(MusicAssistantError, NotImplementedError):
1356 album = await self.get_album(prov_album_id)
1357 album_mapping = ItemMapping.from_item(album) if album else None
1358 for item_count, prov_track in enumerate(prov_tracks, start=1):
1359 self._update_sync_task_item_status(MediaType.TRACK, item_count, prov_track.name)
1360 try:
1361 if prov_track.album is None and album_mapping is not None:
1362 prov_track.album = album_mapping
1363 sync_details = cast(
1364 "TrackSyncDetails | None",
1365 await self.mass.music.tracks.get_library_item_sync_details(
1366 prov_track.provider_mappings,
1367 ),
1368 )
1369 # batch all writes for this item into a single commit
1370 async with self.mass.music.database.deferred_commit():
1371 if not sync_details:
1372 # add item to the library
1373 for prov_map in prov_track.provider_mappings:
1374 prov_map.in_library = True
1375 library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
1376 db_id = int(library_track.item_id)
1377 elif (
1378 not self._check_provider_mappings(sync_details, prov_track, True)
1379 # existing library track but provider mapping doesn't match
1380 # or backfill a missing album(_tracks) link for existing tracks
1381 or (prov_track.album and not sync_details.has_album)
1382 ):
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