A collection of small bugfixes and tweaks (#1392)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 19 Jun 2024 22:51:34 +0000 (00:51 +0200)
committerGitHub <noreply@github.com>
Wed, 19 Jun 2024 22:51:34 +0000 (00:51 +0200)
21 files changed:
music_assistant/common/models/config_entries.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/music.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/multi_client_stream.py
music_assistant/server/helpers/tags.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/filesystem_local/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/hass/__init__.py
music_assistant/server/providers/hass/manifest.json
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/server.py
requirements_all.txt

index 80100bf44beeba6d7304ecf7bad35ebd0c430f6b..b2f00a4c8c326f98986db4ddb24931d172f0b762 100644 (file)
@@ -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(
index 310b9a1190e484f662b6658a0ef43c9cdcd65f13..3130fe5f64c996807d070bbec8eda6c5041c9305 100644 (file)
@@ -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,
         )
index 1713f5c756ffea4890e3566200c17c31ceb5b47c..f393292fa8afa5f6850a3ba6d223ed4e7e12edba 100644 (file)
@@ -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
index 295ef5250d113cef3319f8ce458fa3a84507e6bd..4640c78e732440de9734c1993026ebbd05278693 100644 (file)
@@ -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."""
index 4e95751445000bea0862341116205b43e07a6a2b..3aa30d3274d22553727e4c9008e4dc465541e021 100644 (file)
@@ -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
index 3dea07ecc74a307afcfc30a58520f1f88b7074ee..542b879ab03142773e920d0c4ee36dfbe1cd65ac 100644 (file)
@@ -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):
index 47adf238995d71078039800579c8480911868ac1..149e45a887bcef8fca36eaa2bb8c9fe4329e12ff 100644 (file)
@@ -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(
index 16b400912e5888c070381243fd54764f81a0b33c..1449a6b91eb21a746ee9a96a78839abce1c76214 100644 (file)
@@ -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]:
index faf0f5c5fd8519d9296dcbc44a321af884f3a02e..7f6d0027812c982310e042c885663ad1316b27e6 100644 (file)
@@ -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()
         )
index a7380aca9bd7287892440d1a53ecf15e0cdd67a8..a98d5186ce9eb6ec64a899e1ac51cff07d7b022d 100644 (file)
@@ -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,
index db2ba4d00bcc1506b8380d063279c1f239256cae..7b3c2136e5839f6619eb74ad21b7e11f261c5ab5 100644 (file)
@@ -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,
index da94ee9eaffcebb5ebf58fd951d028977af4db49..6a068dd863a59d43cd72ed9e2c85fd4b35d97628 100644 (file)
@@ -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 = [
index 0d41f3ef0f64b69593cd6d2fe82157ffeea6d7e0..22ab092127dadb20697d2d422b76b3ac7ceb6ec9 100644 (file)
@@ -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:
index 1a25c98fee716f0efb2e719392ceb627b15e0c98..c3d59a6c0d4f1edec38db3a244b48dc47d9d17fe 100644 (file)
@@ -12,6 +12,6 @@
   "load_by_default": false,
   "icon": "md:webhook",
   "requirements": [
-    "hass-client==2.0.0"
+    "hass-client==1.1.1"
   ]
 }
index e45c248fa6b751537a6c1411de6c819a87871d5e..bbebd7cd5944f7bb5b8119441f1f7500ad555a49 100644 (file)
@@ -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)
index 3db9df3693e14c77a53f07ce2f0da661b9058e3c..8b349296a01df1b1ac58284fb28f9471d829fe31 100644 (file)
@@ -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.
 
index fcba7ecb38606e30ae359ec4a5f75e5a9e74f17c..c086ee1790f989f2fece0c9577bc865b5b93b562 100644 (file)
@@ -961,7 +961,7 @@ class SlimprotoProvider(PlayerProvider):
         ):
             try:
                 await resp.write(chunk)
-            except (BrokenPipeError, ConnectionResetError):
+            except (BrokenPipeError, ConnectionResetError, ConnectionError):
                 # race condition
                 break
 
index d45c3cbc93fa3f0f36d7f233468c54af0f8c742b..f6811d51e77618b3593c2b178f61bb76807f1e68 100644 (file)
@@ -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(
index 6eaf3269278364da7faf7ea6dacf9e115abda7f5..fe8416a3959b76f28189d71c2371a0457dceb0fc 100644 (file)
@@ -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:
index 6c6f26f6b3f7102f9bb24c4e3271291e43be5a3d..25aa27b3e60c0ca557c18f1b9b43dc52d4cc3ec9 100644 (file)
@@ -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
index 0c728532e4dc80d6c6a2ed3d49b8cf170c8b4e1b..66e0e4f7e2dc7bd8d5bfb8ad7fc7feae43c81177 100644 (file)
@@ -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