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