/
/
/
1"""Model/base for a Music Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Sequence
7from typing import TYPE_CHECKING, Final, cast
8
9from music_assistant_models.enums import MediaType, ProviderFeature
10from music_assistant_models.errors import (
11 MediaNotFoundError,
12 MusicAssistantError,
13 UnsupportedFeaturedException,
14)
15from music_assistant_models.media_items import (
16 Album,
17 Artist,
18 Audiobook,
19 BrowseFolder,
20 ItemMapping,
21 MediaItemType,
22 Playlist,
23 Podcast,
24 PodcastEpisode,
25 Radio,
26 RecommendationFolder,
27 SearchResults,
28 Track,
29)
30
31from music_assistant.constants import (
32 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
33 CONF_ENTRY_LIBRARY_SYNC_BACK,
34 CONF_ENTRY_LIBRARY_SYNC_DELETIONS,
35 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS,
36)
37
38from .provider import Provider
39
40if TYPE_CHECKING:
41 from collections.abc import AsyncGenerator
42
43 from music_assistant_models.streamdetails import StreamDetails
44
45CACHE_CATEGORY_PREV_LIBRARY_IDS: Final[int] = 1
46
47
48class MusicProvider(Provider):
49 """Base representation of a Music Provider (controller).
50
51 Music Provider implementations should inherit from this base model.
52 """
53
54 @property
55 def is_streaming_provider(self) -> bool:
56 """
57 Return True if the provider is a streaming provider.
58
59 This literally means that the catalog is not the same as the library contents.
60 For local based providers (files, plex), the catalog is the same as the library content.
61 It also means that data is if this provider is NOT a streaming provider,
62 data cross instances is unique, the catalog and library differs per instance.
63
64 Setting this to True will only query one instance of the provider for search and lookups.
65 Setting this to False will query all instances of this provider for search and lookups.
66 """
67 return True
68
69 async def loaded_in_mass(self) -> None:
70 """Call after the provider has been loaded."""
71
72 async def search(
73 self,
74 search_query: str,
75 media_types: list[MediaType],
76 limit: int = 5,
77 ) -> SearchResults:
78 """Perform search on musicprovider.
79
80 :param search_query: Search query.
81 :param media_types: A list of media_types to include.
82 :param limit: Number of items to return in the search (per type).
83 """
84 if ProviderFeature.SEARCH in self.supported_features:
85 raise NotImplementedError
86 return SearchResults()
87
88 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
89 """Retrieve library artists from the provider."""
90 yield # type: ignore[misc]
91 raise NotImplementedError
92
93 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
94 """Retrieve library albums from the provider."""
95 yield # type: ignore[misc]
96 raise NotImplementedError
97
98 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
99 """Retrieve library tracks from the provider."""
100 yield # type: ignore[misc]
101 raise NotImplementedError
102
103 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
104 """Retrieve library/subscribed playlists from the provider."""
105 yield # type: ignore[misc]
106 raise NotImplementedError
107
108 async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
109 """Retrieve library/subscribed radio stations from the provider."""
110 yield # type: ignore[misc]
111 raise NotImplementedError
112
113 async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]:
114 """Retrieve library/subscribed audiobooks from the provider."""
115 yield # type: ignore[misc]
116 raise NotImplementedError
117
118 async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
119 """Retrieve library/subscribed podcasts from the provider."""
120 yield # type: ignore[misc]
121 raise NotImplementedError
122
123 async def get_artist(self, prov_artist_id: str) -> Artist:
124 """Get full artist details by id."""
125 raise NotImplementedError
126
127 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
128 """Get a list of all albums for the given artist.
129
130 Only called if provider supports ProviderFeature.ARTIST_ALBUMS.
131 """
132 raise NotImplementedError
133
134 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
135 """Get a list of most popular tracks for the given artist.
136
137 Only called if provider supports ProviderFeature.ARTIST_TOPTRACKS.
138 """
139 raise NotImplementedError
140
141 async def get_album(self, prov_album_id: str) -> Album:
142 """Get full album details by id.
143
144 Only called if provider supports ProviderFeature.LIBRARY_ALBUMS.
145 """
146 raise NotImplementedError
147
148 async def get_track(self, prov_track_id: str) -> Track:
149 """Get full track details by id.
150
151 Only called if provider supports ProviderFeature.LIBRARY_TRACKS.
152 """
153 raise NotImplementedError
154
155 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
156 """Get full playlist details by id.
157
158 Only called if provider supports ProviderFeature.LIBRARY_PLAYLISTS.
159 """
160 raise NotImplementedError
161
162 async def get_radio(self, prov_radio_id: str) -> Radio:
163 """Get full radio details by id.
164
165 Only called if provider supports ProviderFeature.LIBRARY_RADIOS.
166 """
167 raise NotImplementedError
168
169 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
170 """Get full audiobook details by id.
171
172 Only called if provider supports ProviderFeature.LIBRARY_AUDIOBOOKS.
173 """
174 raise NotImplementedError
175
176 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
177 """Get full podcast details by id.
178
179 Only called if provider supports ProviderFeature.LIBRARY_PODCASTS.
180 """
181 raise NotImplementedError
182
183 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
184 """Get (full) podcast episode details by id.
185
186 Only called if provider supports ProviderFeature.LIBRARY_PODCASTS.
187 """
188 raise NotImplementedError
189
190 async def get_album_tracks(
191 self,
192 prov_album_id: str,
193 ) -> list[Track]:
194 """Get album tracks for given album id.
195
196 Only called if provider supports ProviderFeature.LIBRARY_ALBUMS.
197 """
198 raise NotImplementedError
199
200 async def get_playlist_tracks(
201 self,
202 prov_playlist_id: str,
203 page: int = 0,
204 ) -> list[Track]:
205 """Get all playlist tracks for given playlist id.
206
207 Only called if provider supports ProviderFeature.LIBRARY_PLAYLISTS.
208 """
209 raise NotImplementedError
210
211 async def get_podcast_episodes(
212 self,
213 prov_podcast_id: str,
214 ) -> AsyncGenerator[PodcastEpisode, None]:
215 """Get all PodcastEpisodes for given podcast id.
216
217 Only called if provider supports ProviderFeature.LIBRARY_PODCASTS.
218 """
219 yield # type: ignore[misc]
220 raise NotImplementedError
221
222 async def library_add(self, item: MediaItemType) -> bool:
223 """Add item to provider's library. Return true on success."""
224 if (
225 item.media_type == MediaType.ARTIST
226 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
227 ):
228 raise NotImplementedError
229 if (
230 item.media_type == MediaType.ALBUM
231 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
232 ):
233 raise NotImplementedError
234 if (
235 item.media_type == MediaType.TRACK
236 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
237 ):
238 raise NotImplementedError
239 if (
240 item.media_type == MediaType.PLAYLIST
241 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
242 ):
243 raise NotImplementedError
244 if (
245 item.media_type == MediaType.RADIO
246 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
247 ):
248 raise NotImplementedError
249 if (
250 item.media_type == MediaType.AUDIOBOOK
251 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
252 ):
253 raise NotImplementedError
254 if (
255 item.media_type == MediaType.PODCAST
256 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
257 ):
258 raise NotImplementedError
259 self.logger.info(
260 "Provider %s does not support library edit, "
261 "the action will only be performed in the local database.",
262 self.name,
263 )
264 return True
265
266 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
267 """Remove item from provider's library. Return true on success."""
268 if (
269 media_type == MediaType.ARTIST
270 and ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
271 ):
272 raise NotImplementedError
273 if (
274 media_type == MediaType.ALBUM
275 and ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
276 ):
277 raise NotImplementedError
278 if (
279 media_type == MediaType.TRACK
280 and ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
281 ):
282 raise NotImplementedError
283 if (
284 media_type == MediaType.PLAYLIST
285 and ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
286 ):
287 raise NotImplementedError
288 if (
289 media_type == MediaType.RADIO
290 and ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
291 ):
292 raise NotImplementedError
293 if (
294 media_type == MediaType.AUDIOBOOK
295 and ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
296 ):
297 raise NotImplementedError
298 if (
299 media_type == MediaType.PODCAST
300 and ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
301 ):
302 raise NotImplementedError
303 self.logger.info(
304 "Provider %s does not support library edit, "
305 "the action will only be performed in the local database.",
306 self.name,
307 )
308 return True
309
310 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
311 """
312 Set favorite status for item in provider's library.
313
314 Only called if provider supports ProviderFeature.FAVORITE_*_EDIT.
315
316 Note that this should only be implemented by a provider implementation if
317 the provider differentiates between 'in library' and 'favorited' items.
318 """
319 if (
320 media_type == MediaType.ARTIST
321 and ProviderFeature.FAVORITE_ARTISTS_EDIT in self.supported_features
322 ):
323 raise NotImplementedError
324 if (
325 media_type == MediaType.ALBUM
326 and ProviderFeature.FAVORITE_ALBUMS_EDIT in self.supported_features
327 ):
328 raise NotImplementedError
329 if (
330 media_type == MediaType.TRACK
331 and ProviderFeature.FAVORITE_TRACKS_EDIT in self.supported_features
332 ):
333 raise NotImplementedError
334 if (
335 media_type == MediaType.PLAYLIST
336 and ProviderFeature.FAVORITE_PLAYLISTS_EDIT in self.supported_features
337 ):
338 raise NotImplementedError
339 if (
340 media_type == MediaType.RADIO
341 and ProviderFeature.FAVORITE_RADIOS_EDIT in self.supported_features
342 ):
343 raise NotImplementedError
344 if (
345 media_type == MediaType.AUDIOBOOK
346 and ProviderFeature.FAVORITE_AUDIOBOOKS_EDIT in self.supported_features
347 ):
348 raise NotImplementedError
349 if (
350 media_type == MediaType.PODCAST
351 and ProviderFeature.FAVORITE_PODCASTS_EDIT in self.supported_features
352 ):
353 raise NotImplementedError
354
355 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
356 """Add track(s) to playlist.
357
358 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
359 """
360 raise NotImplementedError
361
362 async def remove_playlist_tracks(
363 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
364 ) -> None:
365 """Remove track(s) from playlist.
366
367 Only called if provider supports ProviderFeature.PLAYLIST_TRACKS_EDIT.
368 """
369 raise NotImplementedError
370
371 async def create_playlist(self, name: str) -> Playlist:
372 """Create a new playlist on provider with given name.
373
374 Only called if provider supports ProviderFeature.PLAYLIST_CREATE.
375 """
376 raise NotImplementedError
377
378 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
379 """Retrieve a dynamic list of similar tracks based on the provided track.
380
381 Only called if provider supports ProviderFeature.SIMILAR_TRACKS.
382 """
383 raise NotImplementedError
384
385 async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
386 """
387 Get progress (resume point) details for the given Audiobook or Podcast episode.
388
389 This is a separate call from the regular get_item call to ensure the resume position
390 is always up-to-date and because a lot providers have this info present on a dedicated
391 endpoint.
392
393 Will be called right before playback starts to ensure the resume position is correct.
394
395 Returns a boolean with the fully_played status
396 and an integer with the resume position in ms.
397 """
398 raise NotImplementedError
399
400 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
401 """Get streamdetails for a track/radio/chapter/episode."""
402 raise NotImplementedError
403
404 async def get_audio_stream(
405 self, streamdetails: StreamDetails, seek_position: int = 0
406 ) -> AsyncGenerator[bytes, None]:
407 """
408 Return the (custom) audio stream for the provider item.
409
410 Will only be called when the stream_type is set to CUSTOM.
411 """
412 yield b""
413 raise NotImplementedError
414
415 async def on_streamed(
416 self,
417 streamdetails: StreamDetails,
418 ) -> None:
419 """
420 Handle callback when given streamdetails completed streaming.
421
422 To get the number of seconds streamed, see streamdetails.seconds_streamed.
423 To get the number of seconds seeked/skipped, see streamdetails.seek_position.
424 Note that seconds_streamed is the total streamed seconds, so without seeked time.
425
426 NOTE: Due to internal and player buffering,
427 this may be called in advance of the actual completion.
428 """
429
430 async def on_played(
431 self,
432 media_type: MediaType,
433 prov_item_id: str,
434 fully_played: bool,
435 position: int,
436 media_item: MediaItemType,
437 is_playing: bool = False,
438 ) -> None:
439 """
440 Handle callback when a (playable) media item has been played.
441
442 This is called by the Queue controller when;
443 - a track has been fully played
444 - a track has been stopped (or skipped) after being played
445 - every 30s when a track is playing
446
447 Fully played is True when the track has been played to the end.
448
449 Position is the last known position of the track in seconds, to sync resume state.
450 When fully_played is set to false and position is 0,
451 the user marked the item as unplayed in the UI.
452
453 media_item is the full media item details of the played/playing track.
454
455 is_playing is True when the track is currently playing.
456 """
457
458 async def resolve_image(self, path: str) -> str | bytes:
459 """
460 Resolve an image from an image path.
461
462 This either returns (a generator to get) raw bytes of the image or
463 a string with an http(s) URL or local path that is accessible from the server.
464 """
465 return path
466
467 async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType:
468 """Get single MediaItem from provider."""
469 if media_type == MediaType.ARTIST:
470 return await self.get_artist(prov_item_id)
471 if media_type == MediaType.ALBUM:
472 return await self.get_album(prov_item_id)
473 if media_type == MediaType.PLAYLIST:
474 return await self.get_playlist(prov_item_id)
475 if media_type == MediaType.RADIO:
476 return await self.get_radio(prov_item_id)
477 if media_type == MediaType.AUDIOBOOK:
478 return await self.get_audiobook(prov_item_id)
479 if media_type == MediaType.PODCAST:
480 return await self.get_podcast(prov_item_id)
481 if media_type == MediaType.PODCAST_EPISODE:
482 return await self.get_podcast_episode(prov_item_id)
483 return await self.get_track(prov_item_id)
484
485 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: # noqa: PLR0911
486 """Browse this provider's items.
487
488 :param path: The path to browse, (e.g. provider_id://artists).
489 """
490 if ProviderFeature.BROWSE not in self.supported_features:
491 # we may NOT use the default implementation if the provider does not support browse
492 raise NotImplementedError
493
494 path_parts = path.split("://")[1].split("/")
495 subpath = path_parts[0] if len(path_parts) > 0 else None
496 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
497 # this reference implementation can be overridden with a provider specific approach
498 if subpath == "artists":
499 if artists := await self.mass.music.artists.library_items(
500 provider=self.instance_id,
501 ):
502 return artists
503 # library items not (yet) synced, fallback to direct retrieval
504 return [x async for x in self.get_library_artists()]
505 if subpath == "albums":
506 if albums := await self.mass.music.albums.library_items(
507 provider=self.instance_id,
508 ):
509 return albums
510 # library items not (yet) synced, fallback to direct retrieval
511 return [x async for x in self.get_library_albums()]
512 if subpath == "tracks":
513 if tracks := await self.mass.music.tracks.library_items(
514 provider=self.instance_id,
515 ):
516 return tracks
517 # library items not (yet) synced, fallback to direct retrieval
518 return [x async for x in self.get_library_tracks()]
519 if subpath == "radios":
520 if radios := await self.mass.music.radio.library_items(
521 provider=self.instance_id,
522 ):
523 return radios
524 # library items not (yet) synced, fallback to direct retrieval
525 return [x async for x in self.get_library_radios()]
526 if subpath == "playlists":
527 if playlists := await self.mass.music.playlists.library_items(
528 provider=self.instance_id,
529 ):
530 return playlists
531 # library items not (yet) synced, fallback to direct retrieval
532 return [x async for x in self.get_library_playlists()]
533 if subpath == "audiobooks":
534 if audiobooks := await self.mass.music.audiobooks.library_items(
535 provider=self.instance_id,
536 ):
537 return audiobooks
538 # library items not (yet) synced, fallback to direct retrieval
539 return [x async for x in self.get_library_audiobooks()]
540 if subpath == "podcasts":
541 if podcasts := await self.mass.music.podcasts.library_items(
542 provider=self.instance_id,
543 ):
544 return podcasts
545 # library items not (yet) synced, fallback to direct retrieval
546 return [x async for x in self.get_library_podcasts()]
547 if subpath == "recommendations" and sub_subpath:
548 # recommendations contents listing
549 recommendations = await self.recommendations()
550 for rec in recommendations:
551 if rec.item_id == sub_subpath:
552 return rec.items
553 if subpath == "recommendations":
554 # Main recommendations listing
555 result: list[BrowseFolder] = []
556 recommendations = await self.recommendations()
557 for rec in recommendations:
558 result.append(
559 BrowseFolder(
560 item_id=rec.item_id,
561 provider=self.instance_id,
562 name=rec.name,
563 is_playable=rec.is_playable,
564 image=rec.image,
565 path=f"{path}/{rec.item_id}",
566 )
567 )
568 return result
569
570 if subpath:
571 # unknown path
572 msg = "Invalid subpath"
573 raise KeyError(msg)
574
575 # no subpath: return main listing
576 folders: list[BrowseFolder] = []
577 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
578 folders.append(
579 BrowseFolder(
580 item_id="artists",
581 provider=self.instance_id,
582 path=path + "artists",
583 name="",
584 translation_key="artists",
585 is_playable=True,
586 )
587 )
588 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
589 folders.append(
590 BrowseFolder(
591 item_id="albums",
592 provider=self.instance_id,
593 path=path + "albums",
594 name="",
595 translation_key="albums",
596 is_playable=True,
597 )
598 )
599 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
600 folders.append(
601 BrowseFolder(
602 item_id="tracks",
603 provider=self.domain,
604 path=path + "tracks",
605 name="",
606 translation_key="tracks",
607 is_playable=True,
608 )
609 )
610 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
611 folders.append(
612 BrowseFolder(
613 item_id="playlists",
614 provider=self.instance_id,
615 path=path + "playlists",
616 name="",
617 translation_key="playlists",
618 is_playable=True,
619 )
620 )
621 if ProviderFeature.LIBRARY_RADIOS in self.supported_features:
622 folders.append(
623 BrowseFolder(
624 item_id="radios",
625 provider=self.instance_id,
626 path=path + "radios",
627 name="",
628 translation_key="radios",
629 )
630 )
631 if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
632 folders.append(
633 BrowseFolder(
634 item_id="audiobooks",
635 provider=self.instance_id,
636 path=path + "audiobooks",
637 name="",
638 translation_key="audiobooks",
639 )
640 )
641 if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
642 folders.append(
643 BrowseFolder(
644 item_id="podcasts",
645 provider=self.instance_id,
646 path=path + "podcasts",
647 name="",
648 translation_key="podcasts",
649 )
650 )
651 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
652 folders.append(
653 BrowseFolder(
654 item_id="recommendations",
655 provider=self.instance_id,
656 path=path + "recommendations",
657 name="",
658 translation_key="recommendations",
659 )
660 )
661 if len(folders) == 1:
662 # only one level, return the items directly
663 return await self.browse(folders[0].path)
664 return folders
665
666 async def recommendations(self) -> list[RecommendationFolder]:
667 """
668 Get this provider's recommendations.
669
670 Returns an actual (and often personalised) list of recommendations
671 from this provider for the user/account.
672 """
673 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
674 raise NotImplementedError
675 return []
676
677 async def sync_library(self, media_type: MediaType) -> None:
678 """Run library sync for this provider."""
679 # this reference implementation may be overridden
680 # with a provider specific approach if needed
681
682 if not self.library_supported(media_type):
683 raise UnsupportedFeaturedException("Library sync not supported for this media type")
684
685 if media_type == MediaType.ARTIST:
686 cur_db_ids = await self._sync_library_artists()
687 elif media_type == MediaType.ALBUM:
688 cur_db_ids = await self._sync_library_albums()
689 elif media_type == MediaType.TRACK:
690 cur_db_ids = await self._sync_library_tracks()
691 elif media_type == MediaType.PLAYLIST:
692 cur_db_ids = await self._sync_library_playlists()
693 elif media_type == MediaType.PODCAST:
694 cur_db_ids = await self._sync_library_podcasts()
695 elif media_type == MediaType.RADIO:
696 cur_db_ids = await self._sync_library_radios()
697 elif media_type == MediaType.AUDIOBOOK:
698 cur_db_ids = await self._sync_library_audiobooks()
699 else:
700 # this should not happen but catch it anyways
701 raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}")
702
703 # process deletions (= no longer in library)
704 controller = self.mass.music.get_controller(media_type)
705 if self.library_sync_deletions_enabled():
706 prev_library_items: list[int] | None
707 if prev_library_items := await self.mass.cache.get(
708 key=media_type.value,
709 provider=self.instance_id,
710 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
711 ):
712 for db_id in prev_library_items:
713 if db_id not in cur_db_ids:
714 try:
715 library_item = await controller.get_library_item(db_id)
716 except MediaNotFoundError:
717 # edge case: the item is (already) removed from MA library as well
718 continue
719 # check if we have other provider-mappings (marked as in-library)
720 remaining_providers_in_library = {
721 x.provider_instance
722 for x in library_item.provider_mappings
723 if x.provider_instance != self.instance_id and x.in_library
724 }
725 if not remaining_providers_in_library and library_item.favorite:
726 # unmark as favorite since no providers have it in library anymore
727 await controller.set_favorite(db_id, False)
728 # unmark this provider mapping as in_library = False
729 # we keep it in the library database so we can keep the metadata
730 for prov_map in library_item.provider_mappings:
731 if prov_map.provider_instance == self.instance_id:
732 prov_map.in_library = False
733 await controller.set_provider_mappings(
734 db_id, library_item.provider_mappings
735 )
736 await asyncio.sleep(0) # yield to eventloop
737 # store current list of id's in cache so we can track changes
738 await self.mass.cache.set(
739 key=media_type.value,
740 data=list(cur_db_ids),
741 provider=self.instance_id,
742 category=CACHE_CATEGORY_PREV_LIBRARY_IDS,
743 )
744
745 async def _sync_library_artists(self) -> set[int]:
746 """Sync Library Artists to Music Assistant library."""
747 self.logger.debug("Start sync of Artists to Music Assistant library.")
748 cur_db_ids: set[int] = set()
749 async for prov_item in self.get_library_artists():
750 library_item = await self.mass.music.artists.get_library_item_by_prov_mappings(
751 prov_item.provider_mappings,
752 )
753 try:
754 if not library_item:
755 # add item to the library
756 for prov_map in prov_item.provider_mappings:
757 prov_map.in_library = True
758 library_item = await self.mass.music.artists.add_item_to_library(prov_item)
759 elif not self._check_provider_mappings(library_item, prov_item, True):
760 # existing library item but provider mapping doesn't match
761 library_item = await self.mass.music.artists.update_item_in_library(
762 library_item.item_id, prov_item
763 )
764 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
765 # update date_added if it changed
766 library_item = await self.mass.music.artists.update_item_in_library(
767 library_item.item_id, prov_item
768 )
769 if not library_item.favorite and prov_item.favorite:
770 # existing library item not favorite but should be
771 await self.mass.music.artists.set_favorite(library_item.item_id, True)
772 cur_db_ids.add(int(library_item.item_id))
773 await asyncio.sleep(0) # yield to eventloop
774 except MusicAssistantError as err:
775 self.logger.warning(
776 "Skipping sync of artist %s - error details: %s",
777 prov_item.uri,
778 str(err),
779 )
780 return cur_db_ids
781
782 async def _sync_library_albums(self) -> set[int]:
783 """Sync Library Albums to Music Assistant library."""
784 self.logger.debug("Start sync of Albums to Music Assistant library.")
785 cur_db_ids: set[int] = set()
786 conf_sync_album_tracks = self.config.get_value(
787 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.key,
788 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS.default_value,
789 )
790 sync_album_tracks = bool(conf_sync_album_tracks)
791 async for prov_item in self.get_library_albums():
792 library_item = await self.mass.music.albums.get_library_item_by_prov_mappings(
793 prov_item.provider_mappings,
794 )
795 try:
796 if not library_item:
797 # add item to the library
798 for prov_map in prov_item.provider_mappings:
799 prov_map.in_library = True
800 library_item = await self.mass.music.albums.add_item_to_library(prov_item)
801 elif not self._check_provider_mappings(library_item, prov_item, True):
802 # existing library item but provider mapping doesn't match
803 library_item = await self.mass.music.albums.update_item_in_library(
804 library_item.item_id, prov_item
805 )
806 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
807 # update date_added if it changed
808 library_item = await self.mass.music.albums.update_item_in_library(
809 library_item.item_id, prov_item
810 )
811 if not library_item.favorite and prov_item.favorite:
812 # existing library item not favorite but should be
813 await self.mass.music.albums.set_favorite(library_item.item_id, True)
814 cur_db_ids.add(int(library_item.item_id))
815 await asyncio.sleep(0) # yield to eventloop
816 # optionally add album tracks to library
817 if sync_album_tracks:
818 await self._sync_album_tracks(prov_item)
819 except MusicAssistantError as err:
820 self.logger.warning(
821 "Skipping sync of album %s - error details: %s",
822 prov_item.uri,
823 str(err),
824 )
825 return cur_db_ids
826
827 async def _sync_album_tracks(self, provider_album: Album) -> None:
828 """Sync Album Tracks to Music Assistant library."""
829 self.logger.debug(
830 "Start sync of Album Tracks to Music Assistant library for album %s.",
831 provider_album.name,
832 )
833 for prov_track in await self.get_album_tracks(provider_album.item_id):
834 library_track = await self.mass.music.tracks.get_library_item_by_prov_mappings(
835 prov_track.provider_mappings,
836 )
837 try:
838 if not library_track:
839 # add item to the library
840 for prov_map in prov_track.provider_mappings:
841 prov_map.in_library = True
842 library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
843 elif not self._check_provider_mappings(library_track, prov_track, True):
844 # existing library track but provider mapping doesn't match
845 library_track = await self.mass.music.tracks.update_item_in_library(
846 library_track.item_id, prov_track
847 )
848 await asyncio.sleep(0) # yield to eventloop
849 except MusicAssistantError as err:
850 self.logger.warning(
851 "Skipping sync of album track %s - error details: %s",
852 prov_track.uri,
853 str(err),
854 )
855
856 async def _sync_library_audiobooks(self) -> set[int]:
857 """Sync Library Audiobooks to Music Assistant library."""
858 self.logger.debug("Start sync of Audiobooks to Music Assistant library.")
859 cur_db_ids: set[int] = set()
860 async for prov_item in self.get_library_audiobooks():
861 library_item = await self.mass.music.audiobooks.get_library_item_by_prov_mappings(
862 prov_item.provider_mappings,
863 )
864 try:
865 if not library_item:
866 # add item to the library
867 for prov_map in prov_item.provider_mappings:
868 prov_map.in_library = True
869 library_item = await self.mass.music.audiobooks.add_item_to_library(prov_item)
870 elif not self._check_provider_mappings(library_item, prov_item, True):
871 # existing library item but provider mapping doesn't match
872 library_item = await self.mass.music.audiobooks.update_item_in_library(
873 library_item.item_id, prov_item
874 )
875 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
876 # update date_added if it changed
877 library_item = await self.mass.music.audiobooks.update_item_in_library(
878 library_item.item_id, prov_item
879 )
880 if not library_item.favorite and prov_item.favorite:
881 # existing library item not favorite but should be
882 await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)
883 # check if resume_position_ms or fully_played changed
884 if (
885 prov_item.resume_position_ms is not None
886 and prov_item.fully_played is not None
887 and (
888 library_item.resume_position_ms != prov_item.resume_position_ms
889 or library_item.fully_played != prov_item.fully_played
890 )
891 ):
892 library_item = await self.mass.music.audiobooks.update_item_in_library(
893 library_item.item_id, prov_item
894 )
895
896 cur_db_ids.add(int(library_item.item_id))
897 await asyncio.sleep(0) # yield to eventloop
898 except MusicAssistantError as err:
899 self.logger.warning(
900 "Skipping sync of audiobook %s - error details: %s",
901 prov_item.uri,
902 str(err),
903 )
904 return cur_db_ids
905
906 async def _sync_library_playlists(self) -> set[int]:
907 """Sync Library Playlists to Music Assistant library."""
908 self.logger.debug("Start sync of Playlists to Music Assistant library.")
909 conf_sync_playlist_tracks = self.config.get_value(
910 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS.key,
911 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS.default_value,
912 )
913 conf_sync_playlist_tracks = cast("list[str]", conf_sync_playlist_tracks)
914 cur_db_ids: set[int] = set()
915 async for prov_item in self.get_library_playlists():
916 library_item = await self.mass.music.playlists.get_library_item_by_prov_mappings(
917 prov_item.provider_mappings,
918 )
919 try:
920 if not library_item:
921 # add item to the library
922 for prov_map in prov_item.provider_mappings:
923 prov_map.in_library = True
924 library_item = await self.mass.music.playlists.add_item_to_library(prov_item)
925 elif not self._check_provider_mappings(library_item, prov_item, True):
926 # existing library item but provider mapping doesn't match
927 library_item = await self.mass.music.playlists.update_item_in_library(
928 library_item.item_id, prov_item
929 )
930 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
931 # update date_added if it changed
932 library_item = await self.mass.music.playlists.update_item_in_library(
933 library_item.item_id, prov_item
934 )
935 if not library_item.favorite and prov_item.favorite:
936 # existing library item not favorite but should be
937 await self.mass.music.playlists.set_favorite(library_item.item_id, True)
938 cur_db_ids.add(int(library_item.item_id))
939 await asyncio.sleep(0) # yield to eventloop
940 # optionally sync playlist tracks
941 if (
942 prov_item.name in conf_sync_playlist_tracks
943 or prov_item.uri in conf_sync_playlist_tracks
944 ):
945 await self._sync_playlist_tracks(prov_item)
946 except MusicAssistantError as err:
947 self.logger.warning(
948 "Skipping sync of playlist %s - error details: %s",
949 prov_item.uri,
950 str(err),
951 )
952 return cur_db_ids
953
954 async def _sync_playlist_tracks(self, provider_playlist: Playlist) -> None:
955 """Sync Playlist Tracks to Music Assistant library."""
956 self.logger.debug(
957 "Start sync of Playlist Tracks to Music Assistant library for playlist %s.",
958 provider_playlist.name,
959 )
960 async for prov_track in self.iter_playlist_tracks(provider_playlist.item_id):
961 library_track = await self.mass.music.tracks.get_library_item_by_prov_mappings(
962 prov_track.provider_mappings,
963 )
964 try:
965 if not library_track:
966 # add item to the library
967 for prov_map in prov_track.provider_mappings:
968 prov_map.in_library = True
969 library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
970 elif not self._check_provider_mappings(library_track, prov_track, True):
971 # existing library track but provider mapping doesn't match
972 library_track = await self.mass.music.tracks.update_item_in_library(
973 library_track.item_id, prov_track
974 )
975 await asyncio.sleep(0) # yield to eventloop
976 except MusicAssistantError as err:
977 self.logger.warning(
978 "Skipping sync of album track %s - error details: %s",
979 prov_track.uri,
980 str(err),
981 )
982
983 async def _sync_library_tracks(self) -> set[int]:
984 """Sync Library Tracks to Music Assistant library."""
985 self.logger.debug("Start sync of Tracks to Music Assistant library.")
986 cur_db_ids: set[int] = set()
987 async for prov_item in self.get_library_tracks():
988 library_item = await self.mass.music.tracks.get_library_item_by_prov_mappings(
989 prov_item.provider_mappings,
990 )
991 try:
992 if not library_item and not prov_item.available:
993 # skip unavailable tracks
994 # TODO: do we want to search for substitutes at this point ?
995 self.logger.debug(
996 "Skipping sync of track %s because it is unavailable",
997 prov_item.uri,
998 )
999 continue
1000 if not library_item:
1001 # add item to the library
1002 for prov_map in prov_item.provider_mappings:
1003 prov_map.in_library = True
1004 library_item = await self.mass.music.tracks.add_item_to_library(prov_item)
1005 elif not self._check_provider_mappings(library_item, prov_item, True):
1006 # existing library item but provider mapping doesn't match
1007 library_item = await self.mass.music.tracks.update_item_in_library(
1008 library_item.item_id, prov_item
1009 )
1010 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
1011 # update date_added if it changed
1012 library_item = await self.mass.music.tracks.update_item_in_library(
1013 library_item.item_id, prov_item
1014 )
1015 if not library_item.favorite and prov_item.favorite:
1016 # existing library item not favorite but should be
1017 await self.mass.music.tracks.set_favorite(library_item.item_id, True)
1018 cur_db_ids.add(int(library_item.item_id))
1019 await asyncio.sleep(0) # yield to eventloop
1020 except MusicAssistantError as err:
1021 self.logger.warning(
1022 "Skipping sync of track %s - error details: %s",
1023 prov_item.uri,
1024 str(err),
1025 )
1026 return cur_db_ids
1027
1028 async def _sync_library_podcasts(self) -> set[int]:
1029 """Sync Library Podcasts to Music Assistant library."""
1030 self.logger.debug("Start sync of Podcasts to Music Assistant library.")
1031 cur_db_ids: set[int] = set()
1032 async for prov_item in self.get_library_podcasts():
1033 library_item = await self.mass.music.podcasts.get_library_item_by_prov_mappings(
1034 prov_item.provider_mappings,
1035 )
1036 try:
1037 if not library_item:
1038 # add item to the library
1039 for prov_map in prov_item.provider_mappings:
1040 prov_map.in_library = True
1041 library_item = await self.mass.music.podcasts.add_item_to_library(prov_item)
1042 elif not self._check_provider_mappings(library_item, prov_item, True):
1043 # existing library item but provider mapping doesn't match
1044 library_item = await self.mass.music.podcasts.update_item_in_library(
1045 library_item.item_id, prov_item
1046 )
1047 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
1048 # update date_added if it changed
1049 library_item = await self.mass.music.podcasts.update_item_in_library(
1050 library_item.item_id, prov_item
1051 )
1052 if not library_item.favorite and prov_item.favorite:
1053 # existing library item not favorite but should be
1054 await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
1055 cur_db_ids.add(int(library_item.item_id))
1056 await asyncio.sleep(0) # yield to eventloop
1057
1058 # precache podcast episodes
1059 async for _ in self.mass.music.podcasts.episodes(
1060 library_item.item_id, library_item.provider
1061 ):
1062 await asyncio.sleep(0) # yield to eventloop
1063 except MusicAssistantError as err:
1064 self.logger.warning(
1065 "Skipping sync of podcast %s - error details: %s",
1066 prov_item.uri,
1067 str(err),
1068 )
1069 return cur_db_ids
1070
1071 async def _sync_library_radios(self) -> set[int]:
1072 """Sync Library Radios to Music Assistant library."""
1073 self.logger.debug("Start sync of Radios to Music Assistant library.")
1074 cur_db_ids: set[int] = set()
1075 async for prov_item in self.get_library_radios():
1076 library_item = await self.mass.music.radio.get_library_item_by_prov_mappings(
1077 prov_item.provider_mappings,
1078 )
1079 try:
1080 if not library_item:
1081 # add item to the library
1082 for prov_map in prov_item.provider_mappings:
1083 prov_map.in_library = True
1084 library_item = await self.mass.music.radio.add_item_to_library(prov_item)
1085 elif not self._check_provider_mappings(library_item, prov_item, True):
1086 # existing library item but provider mapping doesn't match
1087 library_item = await self.mass.music.radio.update_item_in_library(
1088 library_item.item_id, prov_item
1089 )
1090 elif prov_item.date_added and library_item.date_added != prov_item.date_added:
1091 # update date_added if it changed
1092 library_item = await self.mass.music.radio.update_item_in_library(
1093 library_item.item_id, prov_item
1094 )
1095 if not library_item.favorite and prov_item.favorite:
1096 # existing library item not favorite but should be
1097 await self.mass.music.radio.set_favorite(library_item.item_id, True)
1098 cur_db_ids.add(int(library_item.item_id))
1099 await asyncio.sleep(0) # yield to eventloop
1100
1101 except MusicAssistantError as err:
1102 self.logger.warning(
1103 "Skipping sync of Radio %s - error details: %s",
1104 prov_item.uri,
1105 str(err),
1106 )
1107 return cur_db_ids
1108
1109 # DO NOT OVERRIDE BELOW
1110
1111 def library_supported(self, media_type: MediaType) -> bool:
1112 """Return if Library is supported for given MediaType on this provider."""
1113 if media_type == MediaType.ARTIST:
1114 return ProviderFeature.LIBRARY_ARTISTS in self.supported_features
1115 if media_type == MediaType.ALBUM:
1116 return ProviderFeature.LIBRARY_ALBUMS in self.supported_features
1117 if media_type == MediaType.TRACK:
1118 return ProviderFeature.LIBRARY_TRACKS in self.supported_features
1119 if media_type == MediaType.PLAYLIST:
1120 return ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features
1121 if media_type == MediaType.RADIO:
1122 return ProviderFeature.LIBRARY_RADIOS in self.supported_features
1123 if media_type == MediaType.AUDIOBOOK:
1124 return ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features
1125 if media_type == MediaType.PODCAST:
1126 return ProviderFeature.LIBRARY_PODCASTS in self.supported_features
1127 return False
1128
1129 def library_edit_supported(self, media_type: MediaType) -> bool:
1130 """Return if Library add/remove is supported for given MediaType on this provider."""
1131 if media_type == MediaType.ARTIST:
1132 return ProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
1133 if media_type == MediaType.ALBUM:
1134 return ProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
1135 if media_type == MediaType.TRACK:
1136 return ProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
1137 if media_type == MediaType.PLAYLIST:
1138 return ProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
1139 if media_type == MediaType.RADIO:
1140 return ProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
1141 if media_type == MediaType.AUDIOBOOK:
1142 return ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT in self.supported_features
1143 if media_type == MediaType.PODCAST:
1144 return ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
1145 return False
1146
1147 def library_sync_back_enabled(self, media_type: MediaType) -> bool:
1148 """Return if Library sync back is enabled for given MediaType on this provider."""
1149 conf_value = self.config.get_value(
1150 CONF_ENTRY_LIBRARY_SYNC_BACK.key, CONF_ENTRY_LIBRARY_SYNC_BACK.default_value
1151 )
1152 return bool(conf_value)
1153
1154 def library_sync_deletions_enabled(self) -> bool:
1155 """Return if Library sync deletions is enabled for this provider."""
1156 conf_value = self.config.get_value(
1157 CONF_ENTRY_LIBRARY_SYNC_DELETIONS.key, CONF_ENTRY_LIBRARY_SYNC_DELETIONS.default_value
1158 )
1159 return bool(conf_value)
1160
1161 def library_favorites_edit_supported(self, media_type: MediaType) -> bool:
1162 """Return if favorites add/remove is supported for given MediaType on this provider."""
1163 if media_type == MediaType.ARTIST:
1164 return ProviderFeature.FAVORITE_ARTISTS_EDIT in self.supported_features
1165 if media_type == MediaType.ALBUM:
1166 return ProviderFeature.FAVORITE_ALBUMS_EDIT in self.supported_features
1167 if media_type == MediaType.TRACK:
1168 return ProviderFeature.FAVORITE_TRACKS_EDIT in self.supported_features
1169 if media_type == MediaType.PLAYLIST:
1170 return ProviderFeature.FAVORITE_PLAYLISTS_EDIT in self.supported_features
1171 if media_type == MediaType.RADIO:
1172 return ProviderFeature.FAVORITE_RADIOS_EDIT in self.supported_features
1173 if media_type == MediaType.AUDIOBOOK:
1174 return ProviderFeature.FAVORITE_AUDIOBOOKS_EDIT in self.supported_features
1175 if media_type == MediaType.PODCAST:
1176 return ProviderFeature.FAVORITE_PODCASTS_EDIT in self.supported_features
1177 return False
1178
1179 async def iter_playlist_tracks(
1180 self,
1181 prov_playlist_id: str,
1182 ) -> AsyncGenerator[Track, None]:
1183 """Iterate playlist tracks for the given provider playlist id."""
1184 page = 0
1185 while True:
1186 tracks = await self.get_playlist_tracks(
1187 prov_playlist_id,
1188 page=page,
1189 )
1190 if not tracks:
1191 break
1192 for track in tracks:
1193 yield track
1194 page += 1
1195
1196 def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType, None]:
1197 """Return library generator for given media_type."""
1198 if media_type == MediaType.ARTIST:
1199 return self.get_library_artists()
1200 if media_type == MediaType.ALBUM:
1201 return self.get_library_albums()
1202 if media_type == MediaType.TRACK:
1203 return self.get_library_tracks()
1204 if media_type == MediaType.PLAYLIST:
1205 return self.get_library_playlists()
1206 if media_type == MediaType.RADIO:
1207 return self.get_library_radios()
1208 if media_type == MediaType.AUDIOBOOK:
1209 return self.get_library_audiobooks()
1210 if media_type == MediaType.PODCAST:
1211 return self.get_library_podcasts()
1212 raise NotImplementedError
1213
1214 def _check_provider_mappings(
1215 self, library_item: MediaItemType, provider_item: MediaItemType, in_library: bool
1216 ) -> bool:
1217 """Check if provider mapping(s) are consistent between library and provider items."""
1218 for provider_mapping in provider_item.provider_mappings:
1219 if provider_mapping.item_id != provider_item.item_id:
1220 # this should never happen, but guard against it
1221 raise MusicAssistantError("Inconsistent provider mapping item_id found")
1222 if provider_mapping.provider_instance != self.instance_id:
1223 # this should never happen, but guard against it
1224 raise MusicAssistantError("Inconsistent provider mapping instance_id found")
1225 # check if the provider mapping matches the library item
1226 provider_mapping.in_library = in_library
1227 library_mapping = next(
1228 (
1229 x
1230 for x in library_item.provider_mappings
1231 if x.provider_instance == provider_mapping.provider_instance
1232 and x.item_id == provider_mapping.item_id
1233 ),
1234 None,
1235 )
1236 if not library_mapping:
1237 return False
1238 if provider_mapping.in_library != library_mapping.in_library:
1239 # in-library status doesn't match
1240 return False
1241 if provider_mapping.is_unique != library_mapping.is_unique:
1242 # unique status doesn't match
1243 return False
1244 # check if the library item has all provider instances mappings
1245 is_unique = provider_mapping.is_unique or (not self.is_streaming_provider)
1246 if not is_unique:
1247 # for streaming providers we need to make sure all provider instances
1248 # for this domain are represented in the provider mappings
1249 prov_instances = self.mass.music.get_provider_instances(
1250 domain=provider_mapping.provider_domain,
1251 return_unavailable=True,
1252 )
1253 if len(prov_instances) > 1:
1254 # multiple provider instances for this domain exist
1255 # make sure the library item has all provider mappings
1256 for prov_instance in prov_instances:
1257 if not any(
1258 x.provider_instance == prov_instance.instance_id
1259 and x.item_id == provider_mapping.item_id
1260 for x in library_item.provider_mappings
1261 ):
1262 # missing provider mapping for another instance
1263 # the rest of the core logic will take care of adding it
1264 # just return False here to trigger that logic
1265 return False
1266
1267 # final check: availability
1268 return provider_mapping.available == library_mapping.available
1269 return False
1270