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