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