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