From 0421ec92858cd717a1dab6f9408c69323fcdc3f6 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 14 Feb 2025 01:31:56 +0100 Subject: [PATCH] Chore: a couple of small sync tweaks --- music_assistant/controllers/music.py | 34 +-- music_assistant/models/music_provider.py | 201 +++++++++--------- .../_template_music_provider/__init__.py | 4 +- .../providers/audiobookshelf/__init__.py | 4 +- music_assistant/providers/builtin/__init__.py | 63 +++--- .../providers/filesystem_local/__init__.py | 2 +- 6 files changed, 159 insertions(+), 149 deletions(-) diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index b94e8977..bc00c45d 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -200,10 +200,13 @@ class MusicController(CoreController): if providers is None: providers = [x.instance_id for x in self.providers] - for provider in self.providers: - if provider.instance_id not in providers: - continue - self._start_provider_sync(provider, media_types) + for media_type in media_types: + for provider in self.providers: + if provider.instance_id not in providers: + continue + if not provider.library_supported(media_type): + continue + self._start_provider_sync(provider, media_type) @api_command("music/synctasks") def get_running_sync_tasks(self) -> list[SyncTask]: @@ -985,29 +988,26 @@ class MusicController(CoreController): domains.add(provider.domain) return instances - def _start_provider_sync( - self, provider: MusicProvider, media_types: tuple[MediaType, ...] - ) -> None: + def _start_provider_sync(self, provider: MusicProvider, media_type: MediaType) -> None: """Start sync task on provider and track progress.""" # check if we're not already running a sync task for this provider/mediatype for sync_task in self.in_progress_syncs: if sync_task.provider_instance != provider.instance_id: continue - for media_type in media_types: - if media_type in sync_task.media_types: - self.logger.debug( - "Skip sync task for %s because another task is already in progress", - provider.name, - ) - return + if media_type in sync_task.media_types: + self.logger.debug( + "Skip sync task for %s because another task is already in progress", + provider.name, + ) + return async def run_sync() -> None: # Wrap the provider sync into a lock to prevent # race conditions when multiple providers are syncing at the same time. async with self._sync_lock: - await provider.sync_library(media_types) + await provider.sync_library(media_type) # precache playlist tracks - if MediaType.PLAYLIST in media_types: + if media_type == MediaType.PLAYLIST: for playlist in await self.playlists.library_items(provider=provider.instance_id): async for _ in self.playlists.tracks(playlist.item_id, playlist.provider): pass @@ -1017,7 +1017,7 @@ class MusicController(CoreController): sync_spec = SyncTask( provider_domain=provider.domain, provider_instance=provider.instance_id, - media_types=media_types, + media_types=(media_type,), task=task, ) self.in_progress_syncs.append(sync_spec) diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 0d1eedc5..650ff631 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -7,7 +7,11 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, cast from music_assistant_models.enums import CacheCategory, MediaType, ProviderFeature -from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError +from music_assistant_models.errors import ( + MediaNotFoundError, + MusicAssistantError, + UnsupportedFeaturedException, +) from music_assistant_models.media_items import ( Album, Artist, @@ -605,112 +609,111 @@ class MusicProvider(Provider): raise NotImplementedError return [] - async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: + async def sync_library(self, media_type: MediaType) -> None: """Run library sync for this provider.""" # this reference implementation can be overridden # with a provider specific approach if needed - for media_type in media_types: - if not self.library_supported(media_type): - continue - self.logger.debug("Start sync of %s items.", media_type.value) - controller = self.mass.music.get_controller(media_type) - cur_db_ids = set() - async for prov_item in self._get_library_gen(media_type): - library_item = await controller.get_library_item_by_prov_mappings( - prov_item.provider_mappings, + if not self.library_supported(media_type): + raise UnsupportedFeaturedException("Library sync not supported for this media type") + self.logger.debug("Start sync of %s items.", media_type.value) + controller = self.mass.music.get_controller(media_type) + cur_db_ids = set() + async for prov_item in self._get_library_gen(media_type): + library_item = await controller.get_library_item_by_prov_mappings( + prov_item.provider_mappings, + ) + try: + if not library_item and not prov_item.available: + # skip unavailable tracks + self.logger.debug( + "Skipping sync of item %s because it is unavailable", + prov_item.uri, + ) + continue + if not library_item: + # create full db item + # note that we skip the metadata lookup purely to speed up the sync + # the additional metadata is then lazy retrieved afterwards + if self.is_streaming_provider: + prov_item.favorite = True + library_item = await controller.add_item_to_library(prov_item) + elif getattr(library_item, "cache_checksum", None) != getattr( + prov_item, "cache_checksum", None + ): + # existing dbitem checksum changed (playlists only) + library_item = await controller.update_item_in_library( + library_item.item_id, prov_item + ) + if library_item.available != prov_item.available: + # existing item availability changed + library_item = await controller.update_item_in_library( + library_item.item_id, prov_item + ) + # check if resume_position_ms or fully_played changed (audiobook only) + resume_pos_prov = getattr(prov_item, "resume_position_ms", None) + fully_played_prov = getattr(prov_item, "fully_played", None) + if ( + resume_pos_prov is not None + and fully_played_prov is not None + and ( + getattr(library_item, "resume_position_ms", None) != resume_pos_prov + or getattr(library_item, "fully_played", None) != fully_played_prov + ) + ): + library_item = await controller.update_item_in_library( + library_item.item_id, prov_item + ) + await asyncio.sleep(0) # yield to eventloop + except MusicAssistantError as err: + self.logger.warning( + "Skipping sync of item %s - error details: %s", + prov_item.uri, + str(err), ) - try: - if not library_item and not prov_item.available: - # skip unavailable tracks - self.logger.debug( - "Skipping sync of item %s because it is unavailable", - prov_item.uri, - ) + + # process deletions (= no longer in library) + cache_category = CacheCategory.LIBRARY_ITEMS + cache_base_key = self.instance_id + + prev_library_items: list[int] | None + if prev_library_items := await self.mass.cache.get( + media_type.value, category=cache_category, base_key=cache_base_key + ): + for db_id in prev_library_items: + if db_id not in cur_db_ids: + try: + item = await controller.get_library_item(db_id) + except MediaNotFoundError: + # edge case: the item is already removed continue - if not library_item: - # create full db item - # note that we skip the metadata lookup purely to speed up the sync - # the additional metadata is then lazy retrieved afterwards - if self.is_streaming_provider: - prov_item.favorite = True - library_item = await controller.add_item_to_library(prov_item) - elif getattr(library_item, "cache_checksum", None) != getattr( - prov_item, "cache_checksum", None - ): - # existing dbitem checksum changed (playlists only) - library_item = await controller.update_item_in_library( - library_item.item_id, prov_item - ) - if library_item.available != prov_item.available: - # existing item availability changed - library_item = await controller.update_item_in_library( - library_item.item_id, prov_item - ) - # check if resume_position_ms or fully_played changed (audiobook only) - resume_pos_prov = getattr(prov_item, "resume_position_ms", None) - fully_played_prov = getattr(prov_item, "fully_played", None) - if ( - resume_pos_prov is not None - and fully_played_prov is not None - and ( - getattr(library_item, "resume_position_ms", None) != resume_pos_prov - or getattr(library_item, "fully_played", None) != fully_played_prov - ) - ): - library_item = await controller.update_item_in_library( - library_item.item_id, prov_item + remaining_providers = { + x.provider_domain + for x in item.provider_mappings + if x.provider_domain != self.domain + } + if remaining_providers: + continue + # this item is removed from the provider's library + # and we have no other providers attached to it + # it is safe to remove it from the MA library too + # note that we do not remove item's recursively on purpose + try: + await controller.remove_item_from_library(db_id, recursive=False) + except MusicAssistantError as err: + # this is probably because the item still has dependents + self.logger.warning( + "Error removing item %s from library: %s", db_id, str(err) ) + # just un-favorite the item if we can't remove it + await controller.set_favorite(db_id, False) await asyncio.sleep(0) # yield to eventloop - except MusicAssistantError as err: - self.logger.warning( - "Skipping sync of item %s - error details: %s", - prov_item.uri, - str(err), - ) - # process deletions (= no longer in library) - cache_category = CacheCategory.LIBRARY_ITEMS - cache_base_key = self.instance_id - - prev_library_items: list[int] | None - if prev_library_items := await self.mass.cache.get( - media_type.value, category=cache_category, base_key=cache_base_key - ): - for db_id in prev_library_items: - if db_id not in cur_db_ids: - try: - item = await controller.get_library_item(db_id) - except MediaNotFoundError: - # edge case: the item is already removed - continue - remaining_providers = { - x.provider_domain - for x in item.provider_mappings - if x.provider_domain != self.domain - } - if remaining_providers: - continue - # this item is removed from the provider's library - # and we have no other providers attached to it - # it is safe to remove it from the MA library too - # note that we do not remove item's recursively on purpose - try: - await controller.remove_item_from_library(db_id, recursive=False) - except MusicAssistantError as err: - # this is probably because the item still has dependents - self.logger.warning( - "Error removing item %s from library: %s", db_id, str(err) - ) - # just un-favorite the item if we can't remove it - await controller.set_favorite(db_id, False) - await asyncio.sleep(0) # yield to eventloop - - await self.mass.cache.set( - media_type.value, - list(cur_db_ids), - category=cache_category, - base_key=cache_base_key, - ) + await self.mass.cache.set( + media_type.value, + list(cur_db_ids), + category=cache_category, + base_key=cache_base_key, + ) # DO NOT OVERRIDE BELOW diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_template_music_provider/__init__.py index 7bb596e3..b9600575 100644 --- a/music_assistant/providers/_template_music_provider/__init__.py +++ b/music_assistant/providers/_template_music_provider/__init__.py @@ -507,9 +507,9 @@ class MyDemoMusicprovider(MusicProvider): # This is only called if you reported the RECOMMENDATIONS feature in the supported_features. return [] - async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: + async def sync_library(self, media_type: MediaType) -> None: """Run library sync for this provider.""" - # Run a full sync of the library for the given media types. + # Run a full sync of the library for the given media type. # This is called by the music controller to sync items from your provider to the library. # As a generic rule of thumb the default implementation within the MusicProvider # base model should be sufficient for most (streaming) providers. diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 31cc4dc9..bb49da71 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -181,10 +181,10 @@ class Audiobookshelf(MusicProvider): # For streaming providers return True here but for local file based providers return False. return False - async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: + async def sync_library(self, media_type: MediaType) -> None: """Run library sync for this provider.""" await self._client.sync() - await super().sync_library(media_types=media_types) + await super().sync_library(media_type=media_type) def _parse_podcast( self, abs_podcast: ABSLibraryItemExpandedPodcast | ABSLibraryItemMinifiedPodcast diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py index d53f9dec..9dacc398 100644 --- a/music_assistant/providers/builtin/__init__.py +++ b/music_assistant/providers/builtin/__init__.py @@ -186,15 +186,13 @@ class BuiltinProvider(MusicProvider): # always prefer the stored info, such as the name parsed_item.name = stored_item["name"] if image_url := stored_item.get("image_url"): - parsed_item.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.domain, - remotely_accessible=image_url.startswith("http"), - ) - ] + parsed_item.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=self.domain, + remotely_accessible=image_url.startswith("http"), + ) ) return parsed_item @@ -207,15 +205,13 @@ class BuiltinProvider(MusicProvider): # always prefer the stored info, such as the name parsed_item.name = stored_item["name"] if image_url := stored_item.get("image_url"): - parsed_item.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.domain, - remotely_accessible=image_url.startswith("http"), - ) - ] + parsed_item.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=self.domain, + remotely_accessible=image_url.startswith("http"), + ) ) return parsed_item @@ -282,15 +278,13 @@ class BuiltinProvider(MusicProvider): ) playlist.cache_checksum = str(stored_item.get("last_updated")) if image_url := stored_item.get("image_url"): - playlist.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.domain, - remotely_accessible=image_url.startswith("http"), - ) - ] + playlist.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=self.domain, + remotely_accessible=image_url.startswith("http"), + ) ) return playlist @@ -335,8 +329,21 @@ class BuiltinProvider(MusicProvider): for item in stored_items: try: yield await self.get_radio(item["item_id"]) - except MediaNotFoundError as err: + except (MediaNotFoundError, InvalidDataError) as err: self.logger.warning("Radio station %s not found: %s", item, err) + yield Radio( + item_id=item["item_id"], + provider=self.lookup_key, + name=item["name"], + provider_mappings={ + ProviderMapping( + item_id=item["item_id"], + provider_domain=self.domain, + provider_instance=self.instance_id, + available=False, + ) + }, + ) async def library_add(self, item: MediaItemType) -> bool: """Add item to provider's library. Return true on success.""" diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index e1f1c8bd..d5eb0325 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -300,7 +300,7 @@ class LocalFileSystemProvider(MusicProvider): ) return items - async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: + async def sync_library(self, media_type: MediaType) -> None: """Run library sync for this provider.""" assert self.mass.music.database start_time = time.time() -- 2.34.1