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