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