From: Marcel van der Veldt Date: Wed, 19 Jun 2024 22:51:34 +0000 (+0200) Subject: A collection of small bugfixes and tweaks (#1392) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=921faca09d5b1d052f9ef3551c531ea6b13d07a3;p=music-assistant-server.git A collection of small bugfixes and tweaks (#1392) --- diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 80100bf4..b2f00a4c 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -346,13 +346,12 @@ CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED = ConfigEntry.from_dict( {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True} ) -CONF_ENTRY_FLOW_MODE_ENFORCED = ConfigEntry( - key=CONF_FLOW_MODE, - type=ConfigEntryType.BOOLEAN, - label=CONF_FLOW_MODE, - default_value=True, - value=True, - hidden=True, +CONF_ENTRY_FLOW_MODE_ENFORCED = ConfigEntry.from_dict( + {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True, "value": True, "hidden": True} +) + +CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED = ConfigEntry.from_dict( + {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": False, "value": False, "hidden": True} ) CONF_ENTRY_AUTO_PLAY = ConfigEntry( diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index 310b9a11..3130fe5f 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -77,6 +77,7 @@ class ArtistsController(MediaControllerBase[Artist]): limit: int = 500, offset: int = 0, order_by: str = "sort_name", + provider: str | None = None, extra_query: str | None = None, extra_query_params: dict[str, Any] | None = None, album_artists_only: bool = False, @@ -94,6 +95,7 @@ class ArtistsController(MediaControllerBase[Artist]): limit=limit, offset=offset, order_by=order_by, + provider=provider, extra_query=extra_query, extra_query_params=extra_query_params, ) diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index 1713f5c7..f393292f 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -206,6 +206,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): limit: int = 500, offset: int = 0, order_by: str = "sort_name", + provider: str | None = None, extra_query: str | None = None, extra_query_params: dict[str, Any] | None = None, ) -> list[ItemCls]: @@ -222,6 +223,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): limit=limit, offset=offset, order_by=order_by, + provider=provider, extra_query=extra_query, extra_query_params=extra_query_params, ) @@ -747,6 +749,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): limit: int = 500, offset: int = 0, order_by: str | None = None, + provider: str | None = None, extra_query: str | None = None, extra_query_params: dict[str, Any] | None = None, ) -> list[ItemCls] | int: @@ -772,6 +775,10 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): if favorite is not None: query_parts.append(f"{self.db_table}.favorite = :favorite") query_params["favorite"] = favorite + # handle provider filter + if provider: + query_parts.append(f"{DB_TABLE_PROVIDER_MAPPINGS}.provider_instance = :provider") + query_params["provider"] = provider # handle extra/custom query if extra_query: # prevent duplicate where statement diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 295ef525..4640c78e 100644 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -347,7 +347,7 @@ class MusicController(CoreController): BrowseFolder(item_id="back", provider=provider_instance, path=back_path, name="..") ) # limit -1 to account for the prepended items - prov_items = await prov.browse(path, offset=offset, limit=limit) + prov_items = await prov.browse(path=path, offset=offset, limit=limit) return prepend_items + prov_items @api_command("music/recently_played_items") @@ -894,8 +894,12 @@ class MusicController(CoreController): await self.__create_database_triggers() # compact db self.logger.debug("Compacting database...") - await self.database.vacuum() - self.logger.debug("Compacting database done") + try: + await self.database.vacuum() + except Exception as err: + self.logger.warning("Database vacuum failed: %s", str(err)) + else: + self.logger.debug("Compacting database done") async def __migrate_database(self, prev_version: int) -> None: """Perform a database migration.""" diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 4e957514..3aa30d32 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -128,11 +128,6 @@ class FFMpeg(AsyncProcess): """Close/terminate the process and wait for exit.""" if self._stdin_task and not self._stdin_task.done(): self._stdin_task.cancel() - # make sure the stdin generator is also properly closed - # by propagating a cancellederror within - with suppress(RuntimeError): - task = asyncio.create_task(self.audio_input.__anext__()) - task.cancel() if not self.collect_log_history: await super().close(send_signal) return diff --git a/music_assistant/server/helpers/multi_client_stream.py b/music_assistant/server/helpers/multi_client_stream.py index 3dea07ec..542b879a 100644 --- a/music_assistant/server/helpers/multi_client_stream.py +++ b/music_assistant/server/helpers/multi_client_stream.py @@ -58,7 +58,7 @@ class MultiClientStream: async def subscribe_raw(self) -> AsyncGenerator[bytes, None]: """Subscribe to the raw/unaltered audio stream.""" try: - queue = asyncio.Queue(1) + queue = asyncio.Queue(2) self.subscribers.append(queue) while True: chunk = await queue.get() @@ -89,9 +89,9 @@ class MultiClientStream: async for chunk in self.audio_source: if len(self.subscribers) == 0: return - async with asyncio.TaskGroup() as tg: - for sub in list(self.subscribers): - tg.create_task(sub.put(chunk)) + await asyncio.gather( + *[sub.put(chunk) for sub in self.subscribers], return_exceptions=True + ) # EOF: send empty chunk async with asyncio.TaskGroup() as tg: for sub in list(self.subscribers): diff --git a/music_assistant/server/helpers/tags.py b/music_assistant/server/helpers/tags.py index 47adf238..149e45a8 100644 --- a/music_assistant/server/helpers/tags.py +++ b/music_assistant/server/helpers/tags.py @@ -6,6 +6,7 @@ import asyncio import json import logging import os +from collections.abc import Iterable from dataclasses import dataclass from json import JSONDecodeError from typing import TYPE_CHECKING, Any @@ -29,6 +30,11 @@ LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.tags") TAG_SPLITTER = ";" +def clean_tuple(values: Iterable[str]) -> tuple: + """Return a tuple with all empty values removed.""" + return tuple(x.strip() for x in values if x not in (None, "", " ")) + + def split_items(org_str: str, allow_unsafe_splitters: bool = False) -> tuple[str, ...]: """Split up a tags string by common splitter.""" if org_str is None: @@ -37,12 +43,12 @@ def split_items(org_str: str, allow_unsafe_splitters: bool = False) -> tuple[str return (x.strip() for x in org_str) org_str = org_str.strip() if TAG_SPLITTER in org_str: - return tuple(x.strip() for x in org_str.split(TAG_SPLITTER)) + return clean_tuple(org_str.split(TAG_SPLITTER)) if allow_unsafe_splitters and "/" in org_str: - return tuple(x.strip() for x in org_str.split("/")) + return clean_tuple(org_str.split("/")) if allow_unsafe_splitters and ", " in org_str: - return tuple(x.strip() for x in org_str.split(", ")) - return (org_str.strip(),) + return clean_tuple(org_str.split(", ")) + return clean_tuple((org_str,)) def split_artists( diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index 16b40091..1449a6b9 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -290,87 +290,86 @@ class MusicProvider(Provider): if ProviderFeature.BROWSE not in self.supported_features: # we may NOT use the default implementation if the provider does not support browse raise NotImplementedError - items: list[MediaItemType] = [] - index = -1 + subpath = path.split("://", 1)[1] # this reference implementation can be overridden with a provider specific approach - generator: AsyncGenerator[MediaItemType, None] | None = None if subpath == "artists": - generator = self.get_library_artists() - elif subpath == "albums": - generator = self.get_library_albums() - elif subpath == "tracks": - generator = self.get_library_tracks() - elif subpath == "radios": - generator = self.get_library_radios() - elif subpath == "playlists": - generator = self.get_library_playlists() - elif subpath: + return await self.mass.music.artists.library_items( + limit=limit, offset=offset, provider=self.instance_id + ) + if subpath == "albums": + return await self.mass.music.albums.library_items( + limit=limit, offset=offset, provider=self.instance_id + ) + if subpath == "tracks": + return await self.mass.music.tracks.library_items( + limit=limit, offset=offset, provider=self.instance_id + ) + if subpath == "radios": + return await self.mass.music.radio.library_items( + limit=limit, offset=offset, provider=self.instance_id + ) + if subpath == "playlists": + return await self.mass.music.playlists.library_items( + limit=limit, offset=offset, provider=self.instance_id + ) + if subpath: # unknown path msg = "Invalid subpath" raise KeyError(msg) - if generator: - # grab items from library generator - async for item in generator: - index += 1 - if index < offset: - continue - items.append(item) - if len(items) >= limit: - break - else: - # no subpath: return main listing - if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: - items.append( - BrowseFolder( - item_id="artists", - provider=self.domain, - path=path + "artists", - name="", - label="artists", - ) + # no subpath: return main listing + items: list[MediaItemType] = [] + if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: + items.append( + BrowseFolder( + item_id="artists", + provider=self.domain, + path=path + "artists", + name="", + label="artists", ) - if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: - items.append( - BrowseFolder( - item_id="albums", - provider=self.domain, - path=path + "albums", - name="", - label="albums", - ) + ) + if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: + items.append( + BrowseFolder( + item_id="albums", + provider=self.domain, + path=path + "albums", + name="", + label="albums", ) - if ProviderFeature.LIBRARY_TRACKS in self.supported_features: - items.append( - BrowseFolder( - item_id="tracks", - provider=self.domain, - path=path + "tracks", - name="", - label="tracks", - ) + ) + if ProviderFeature.LIBRARY_TRACKS in self.supported_features: + items.append( + BrowseFolder( + item_id="tracks", + provider=self.domain, + path=path + "tracks", + name="", + label="tracks", ) - if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: - items.append( - BrowseFolder( - item_id="playlists", - provider=self.domain, - path=path + "playlists", - name="", - label="playlists", - ) + ) + if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: + items.append( + BrowseFolder( + item_id="playlists", + provider=self.domain, + path=path + "playlists", + name="", + label="playlists", ) - if ProviderFeature.LIBRARY_RADIOS in self.supported_features: - items.append( - BrowseFolder( - item_id="radios", - provider=self.domain, - path=path + "radios", - name="", - label="radios", - ) + ) + if ProviderFeature.LIBRARY_RADIOS in self.supported_features: + items.append( + BrowseFolder( + item_id="radios", + provider=self.domain, + path=path + "radios", + name="", + label="radios", ) + ) return items async def recommendations(self) -> list[MediaItemType]: diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index faf0f5c5..7f6d0027 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -571,6 +571,7 @@ class AirplayProvider(PlayerProvider): CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION, CONF_ENTRY_SAMPLE_RATES_AIRPLAY, + CONF_ENTRY_FLOW_MODE_ENFORCED, ) return (*base_entries, *PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_AIRPLAY) @@ -618,7 +619,7 @@ class AirplayProvider(PlayerProvider): # prefer interactive command to our streamer await airplay_player.active_stream.send_cli_command("ACTION=PAUSE") - async def play_media( # noqa: PLR0915 + async def play_media( self, player_id: str, media: PlayerMedia, @@ -705,29 +706,30 @@ class AirplayProvider(PlayerProvider): chunk = await buffer.get() if chunk == b"EOF": break - async with asyncio.TaskGroup() as tg: - for airplay_player in sync_clients: - tg.create_task(airplay_player.active_stream.write_chunk(chunk)) + await asyncio.gather( + *[x.active_stream.write_chunk(chunk) for x in sync_clients], + return_exceptions=True, + ) # entire stream consumed: send EOF - for airplay_player in sync_clients: - self.mass.create_task(airplay_player.active_stream.write_eof()) + await asyncio.gather( + *[x.active_stream.write_eof() for x in sync_clients], + return_exceptions=True, + ) + finally: if not fill_buffer_task.done(): fill_buffer_task.cancel() - # make sure the stdin generator is also properly closed - # by propagating a cancellederror within - task = asyncio.create_task(audio_source.__anext__()) - task.cancel() empty_queue(buffer) # get current ntp and start cliraop _, stdout = await check_output(f"{self.cliraop_bin} -ntp") start_ntp = int(stdout.strip()) wait_start = 1250 + (250 * len(sync_clients)) - async with asyncio.TaskGroup() as tg: - for airplay_player in self._get_sync_clients(player_id): - tg.create_task(airplay_player.active_stream.start(start_ntp, wait_start)) + await asyncio.gather( + *[x.active_stream.start(start_ntp, wait_start) for x in sync_clients], + return_exceptions=True, + ) self._players[player_id].active_stream.audio_source_task = asyncio.create_task( audio_streamer() ) diff --git a/music_assistant/server/providers/filesystem_local/__init__.py b/music_assistant/server/providers/filesystem_local/__init__.py index a7380aca..a98d5186 100644 --- a/music_assistant/server/providers/filesystem_local/__init__.py +++ b/music_assistant/server/providers/filesystem_local/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import os import os.path +import re from typing import TYPE_CHECKING import aiofiles @@ -76,10 +77,15 @@ async def get_config_entries( ) -async def create_item(base_path: str, entry: os.DirEntry) -> FileSystemItem: - """Create FileSystemItem from os.DirEntry.""" +def sorted_scandir(base_path: str, sub_path: str) -> list[os.DirEntry]: + """Implement os.scandir that returns (naturally) sorted entries.""" - def _create_item(): + def nat_key(name: str) -> tuple[int, str]: + """Sort key for natural sorting.""" + return tuple(int(s) if s.isdigit() else s for s in re.split(r"(\d+)", name)) + + def create_item(entry: os.DirEntry): + """Create FileSystemItem from os.DirEntry.""" absolute_path = get_absolute_path(base_path, entry.path) stat = entry.stat(follow_symlinks=False) return FileSystemItem( @@ -94,8 +100,16 @@ async def create_item(base_path: str, entry: os.DirEntry) -> FileSystemItem: local_path=absolute_path, ) - # run in thread because strictly taken this may be blocking IO - return await asyncio.to_thread(_create_item) + return sorted( + # filter out invalid dirs and hidden files + [ + create_item(x) + for x in os.scandir(sub_path) + if x.name not in IGNORE_DIRS and not x.name.startswith(".") + ], + # sort by (natural) name + key=lambda x: nat_key(x.name), + ) class LocalFileSystemProvider(FileSystemProviderBase): @@ -132,20 +146,15 @@ class LocalFileSystemProvider(FileSystemProviderBase): """ abs_path = get_absolute_path(self.base_path, path) - entries = await asyncio.to_thread(os.scandir, abs_path) - for entry in entries: - if entry.name.startswith(".") or any(x in entry.name for x in IGNORE_DIRS): - # skip invalid/system files and dirs - continue - item = await create_item(self.base_path, entry) - if recursive and item.is_dir: + for entry in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path): + if recursive and entry.is_dir: try: - async for subitem in self.listdir(item.absolute_path, True): + async for subitem in self.listdir(entry.absolute_path, True): yield subitem except (OSError, PermissionError) as err: - self.logger.warning("Skip folder %s: %s", item.path, str(err)) + self.logger.warning("Skip folder %s: %s", entry.path, str(err)) else: - yield item + yield entry async def resolve( self, diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index db2ba4d0..7b3c2136 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -258,19 +258,18 @@ class FileSystemProviderBase(MusicProvider): :param path: The path to browse, (e.g. provid://artists). """ + if offset: + # we do not support pagination + return [] items: list[MediaItemType] = [] item_path = path.split("://", 1)[1] if not item_path: item_path = "" - index = 0 async for item in self.listdir(item_path, recursive=False): if not item.is_dir and ("." not in item.filename or not item.ext): # skip system files and files without extension continue - if index < offset: - continue - if item.is_dir: items.append( BrowseFolder( @@ -298,9 +297,6 @@ class FileSystemProviderBase(MusicProvider): name=item.filename, ) ) - index += 1 - if len(items) >= limit: - break return items async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: @@ -385,8 +381,9 @@ class FileSystemProviderBase(MusicProvider): f"SELECT item_id FROM {DB_TABLE_ARTISTS} " f"WHERE item_id not in " f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} " - f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} )" - f"AND provider_instance = '{self.instance_id}'" + f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} ) " + f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} " + f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )" ) for db_row in await self.mass.music.database.get_rows_from_query( query, diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index da94ee9e..6a068dd8 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -186,7 +186,7 @@ class SMBFileSystemProvider(LocalFileSystemProvider): if platform.system() == "Darwin": password_str = f":{password}" if password else "" - mount_cmd = f'mount -t smbfs "//{username}{password_str}@{server}/{share}{subfolder}" "{self.base_path}"' # noqa: E501 + mount_cmd = f'mount -t smbfs "//{username}:{password_str}@{server}/{share}{subfolder}" "{self.base_path}"' # noqa: E501 elif platform.system() == "Linux": options = [ diff --git a/music_assistant/server/providers/hass/__init__.py b/music_assistant/server/providers/hass/__init__.py index 0d41f3ef..22ab0921 100644 --- a/music_assistant/server/providers/hass/__init__.py +++ b/music_assistant/server/providers/hass/__init__.py @@ -27,7 +27,7 @@ from hass_client.utils import ( from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ConfigEntryType -from music_assistant.common.models.errors import LoginFailed +from music_assistant.common.models.errors import LoginFailed, SetupFailedError from music_assistant.constants import MASS_LOGO_ONLINE from music_assistant.server.helpers.auth import AuthenticationHelper from music_assistant.server.models.plugin import PluginProvider @@ -162,7 +162,10 @@ class HomeAssistant(PluginProvider): token = self.config.get_value(CONF_AUTH_TOKEN) logging.getLogger("hass_client").setLevel(self.logger.level + 10) self.hass = HomeAssistantClient(url, token, self.mass.http_session) - await self.hass.connect() + try: + await self.hass.connect() + except BaseHassClientError as err: + raise SetupFailedError from err self._listen_task = self.mass.create_task(self._hass_listener()) async def unload(self) -> None: diff --git a/music_assistant/server/providers/hass/manifest.json b/music_assistant/server/providers/hass/manifest.json index 1a25c98f..c3d59a6c 100644 --- a/music_assistant/server/providers/hass/manifest.json +++ b/music_assistant/server/providers/hass/manifest.json @@ -12,6 +12,6 @@ "load_by_default": false, "icon": "md:webhook", "requirements": [ - "hass-client==2.0.0" + "hass-client==1.1.1" ] } diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index e45c248f..bbebd7cd 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -31,7 +31,12 @@ from music_assistant.common.models.enums import ( ProviderFeature, StreamType, ) -from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError +from music_assistant.common.models.errors import ( + InvalidDataError, + LoginFailed, + MediaNotFoundError, + SetupFailedError, +) from music_assistant.common.models.media_items import ( Album, Artist, @@ -348,8 +353,13 @@ class PlexProvider(MusicProvider): self._myplex_account = await self.get_myplex_account_and_refresh_token( self.config.get_value(CONF_AUTH_TOKEN) ) - self._plex_server = await self._run_async(connect) - self._plex_library = await self._run_async(self._plex_server.library.section, library_name) + try: + self._plex_server = await self._run_async(connect) + self._plex_library = await self._run_async( + self._plex_server.library.section, library_name + ) + except requests.exceptions.ConnectionError as err: + raise SetupFailedError from err @property def supported_features(self) -> tuple[ProviderFeature, ...]: @@ -385,7 +395,7 @@ class PlexProvider(MusicProvider): async def _run_async(self, call: Callable, *args, **kwargs): await self.get_myplex_account_and_refresh_token(self.config.get_value(CONF_AUTH_TOKEN)) - return await self.mass.create_task(call, *args, **kwargs) + return await asyncio.to_thread(call, *args, **kwargs) async def _get_data(self, key, cls=None): return await self._run_async(self._plex_library.fetchItem, key, cls) diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index 3db9df36..8b349296 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -21,6 +21,7 @@ from music_assistant.common.models.media_items import ( SearchResults, ) from music_assistant.common.models.streamdetails import StreamDetails +from music_assistant.server.controllers.cache import use_cache from music_assistant.server.models.music_provider import MusicProvider SUPPORTED_FEATURES = (ProviderFeature.SEARCH, ProviderFeature.BROWSE) @@ -101,6 +102,7 @@ class RadioBrowserProvider(MusicProvider): return result + @use_cache(86400 * 7) async def browse(self, path: str, offset: int, limit: int) -> list[MediaItemType]: """Browse this provider's items. diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index fcba7ecb..c086ee17 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -961,7 +961,7 @@ class SlimprotoProvider(PlayerProvider): ): try: await resp.write(chunk) - except (BrokenPipeError, ConnectionResetError): + except (BrokenPipeError, ConnectionResetError, ConnectionError): # race condition break diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index d45c3cbc..f6811d51 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -21,6 +21,7 @@ from sonos_websocket.exception import SonosWebsocketError from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE, + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, ConfigEntry, ConfigValueType, create_sample_rates_config_entry, @@ -186,7 +187,7 @@ class SonosPlayerProvider(PlayerProvider): base_entries = await super().get_player_config_entries(player_id) if not (sonos_player := self.sonosplayers.get(player_id)): # most probably a syncgroup - return (*base_entries, CONF_ENTRY_CROSSFADE) + return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED) is_s2 = sonos_player.soco.speaker_info["model_name"] in S2_MODELS return ( *base_entries, @@ -221,6 +222,7 @@ class SonosPlayerProvider(PlayerProvider): category="advanced", ), CONF_ENTRY_SAMPLE_RATES_SONOS_S2 if is_s2 else CONF_ENTRY_SAMPLE_RATES_SONOS_S1, + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, ) def on_player_config_changed( diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 6eaf3269..fe8416a3 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -346,8 +346,12 @@ class TidalProvider(MusicProvider): async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: """Get a list of 10 most popular tracks for the given artist.""" tidal_session = await self._get_tidal_session() - artist_toptracks_obj = await get_artist_toptracks(tidal_session, prov_artist_id) - return [self._parse_track(track) for track in artist_toptracks_obj] + try: + artist_toptracks_obj = await get_artist_toptracks(tidal_session, prov_artist_id) + return [self._parse_track(track) for track in artist_toptracks_obj] + except tidal_exceptions.ObjectNotFound as err: + self.logger.warning(f"Failed to get toptracks for artist {prov_artist_id}: {err}") + return [] async def get_playlist_tracks( self, prov_playlist_id: str, offset: int, limit: int @@ -451,27 +455,36 @@ class TidalProvider(MusicProvider): async def get_artist(self, prov_artist_id: str) -> Artist: """Get artist details for given artist id.""" tidal_session = await self._get_tidal_session() - artist_obj = await get_artist(tidal_session, prov_artist_id) - return self._parse_artist(artist_obj) + try: + artist_obj = await get_artist(tidal_session, prov_artist_id) + return self._parse_artist(artist_obj) + except tidal_exceptions.ObjectNotFound as err: + raise MediaNotFoundError from err @throttle_with_retries async def get_album(self, prov_album_id: str) -> Album: """Get album details for given album id.""" tidal_session = await self._get_tidal_session() - album_obj = await get_album(tidal_session, prov_album_id) - return self._parse_album(album_obj) + try: + album_obj = await get_album(tidal_session, prov_album_id) + return self._parse_album(album_obj) + except tidal_exceptions.ObjectNotFound as err: + raise MediaNotFoundError from err @throttle_with_retries async def get_track(self, prov_track_id: str) -> Track: """Get track details for given track id.""" tidal_session = await self._get_tidal_session() track_obj = await get_track(tidal_session, prov_track_id) - track = self._parse_track(track_obj) - # get some extra details for the full track info - with suppress(tidal_exceptions.MetadataNotAvailable, AttributeError): - lyrics: TidalLyrics = await asyncio.to_thread(track.lyrics) - track.metadata.lyrics = lyrics.text - return track + try: + track = self._parse_track(track_obj) + # get some extra details for the full track info + with suppress(tidal_exceptions.MetadataNotAvailable, AttributeError): + lyrics: TidalLyrics = await asyncio.to_thread(track.lyrics) + track.metadata.lyrics = lyrics.text + return track + except tidal_exceptions.ObjectNotFound as err: + raise MediaNotFoundError from err @throttle_with_retries async def get_playlist(self, prov_playlist_id: str) -> Playlist: diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 6c6f26f6..25aa27b3 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -19,7 +19,7 @@ from music_assistant.common.helpers.global_cache import set_global_cache_values from music_assistant.common.helpers.util import get_ip_pton from music_assistant.common.models.api import ServerInfoMessage from music_assistant.common.models.enums import EventType, ProviderType -from music_assistant.common.models.errors import SetupFailedError +from music_assistant.common.models.errors import MusicAssistantError, SetupFailedError from music_assistant.common.models.event import MassEvent from music_assistant.common.models.provider import ProviderManifest from music_assistant.constants import ( @@ -308,9 +308,10 @@ class MusicAssistant: def create_task( self, - target: Coroutine | Awaitable | Callable | asyncio.Future, + target: Coroutine | Awaitable | Callable, *args: Any, task_id: str | None = None, + eager_start: bool = False, **kwargs: Any, ) -> asyncio.Task | asyncio.Future: """Create Task on (main) event loop from Coroutine(function). @@ -324,17 +325,26 @@ class MusicAssistant: # prevent duplicate tasks if task_id is given and already present return existing if asyncio.iscoroutinefunction(target): - task = self.loop.create_task(target(*args, **kwargs)) + # coroutine function (with or without eager start) + if eager_start: + task = asyncio.Task(target(*args, **kwargs), loop=self.loop, eager_start=True) + else: + task = self.loop.create_task(target(*args, **kwargs)) elif asyncio.iscoroutine(target): - task = self.loop.create_task(target) - elif isinstance(target, asyncio.Future): - task = target + # coroutine (with or without eager start) + if eager_start: + task = asyncio.Task(target, loop=self.loop, eager_start=True) + else: + task = self.loop.create_task(target) + elif eager_start: + # regular callback (non async function) + task = asyncio.Task( + asyncio.to_thread(target, *args, **kwargs), loop=self.loop, eager_start=True + ) else: - # assume normal callable (non coroutine or awaitable) - # that needs to be run in the executor task = self.loop.create_task(asyncio.to_thread(target, *args, **kwargs)) - def task_done_callback(_task: asyncio.Future | asyncio.Task) -> None: + def task_done_callback(_task: asyncio.Task) -> None: _task_id = task.task_id self._tracked_tasks.pop(_task_id) # log unhandled exceptions @@ -362,7 +372,7 @@ class MusicAssistant: def call_later( self, delay: float, - target: Coroutine | Awaitable | Callable | asyncio.Future, + target: Coroutine | Awaitable | Callable, *args: Any, task_id: str | None = None, **kwargs: Any, @@ -386,7 +396,7 @@ class MusicAssistant: self._tracked_timers[task_id] = handle return handle - def get_task(self, task_id: str) -> asyncio.Task | asyncio.Future: + def get_task(self, task_id: str) -> asyncio.Task: """Get existing scheduled task.""" if existing := self._tracked_tasks.get(task_id): # prevent duplicate tasks if task_id is given and already present @@ -416,10 +426,18 @@ class MusicAssistant: await self._load_provider(prov_conf) # pylint: disable=broad-except except Exception as exc: - LOGGER.exception( - "Error loading provider(instance) %s", - prov_conf.name or prov_conf.domain, - ) + if isinstance(exc, MusicAssistantError): + LOGGER.error( + "Error loading provider(instance) %s: %s", + prov_conf.name or prov_conf.domain, + str(exc), + ) + else: + # log full stack trace on unhandled/generic exception + LOGGER.exception( + "Error loading provider(instance) %s", + prov_conf.name or prov_conf.domain, + ) if raise_on_error: raise # if loading failed, we store the error in the config object diff --git a/requirements_all.txt b/requirements_all.txt index 0c728532..66e0e4f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -18,7 +18,7 @@ deezer-python-async==0.3.0 defusedxml==0.7.1 faust-cchardet>=2.1.18 git+https://github.com/MarvinSchenkel/pytube.git -hass-client==2.0.0 +hass-client==1.1.1 ifaddr==0.2.0 mashumaro==3.13.1 memory-tempfile==2.2.3