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