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