Various improvements and fixes (#522)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 13 Mar 2023 19:16:25 +0000 (20:16 +0100)
committerGitHub <noreply@github.com>
Mon, 13 Mar 2023 19:16:25 +0000 (20:16 +0100)
* set default startup volume for airplay

* fix delete provider order

* fix zeroconf discovery

* fix some typos

* reduce save delay

* extend prebuffer amount

* prevent duplicate tasks: prevent the same media item being added at the same time

* Fix issues with json serializer and encrypted passwords

* send changed keys to player changed callback

* rework json serializer

* add ca-certificates to dockerfile

* update documentation links

* bump frontend to 20230313.0

33 files changed:
Dockerfile
music_assistant/common/helpers/json.py
music_assistant/common/models/api.py
music_assistant/common/models/config_entries.py
music_assistant/common/models/enums.py
music_assistant/common/models/event.py
music_assistant/common/models/provider.py
music_assistant/constants.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/streams.py
music_assistant/server/helpers/api.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/chromecast/manifest.json
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/dlna/manifest.json
music_assistant/server/providers/filesystem_smb/manifest.json
music_assistant/server/providers/frontend/manifest.json
music_assistant/server/providers/qobuz/manifest.json
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/manifest.json
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/spotify/manifest.json
music_assistant/server/providers/ytmusic/manifest.json
music_assistant/server/server.py
requirements_all.txt

index 5110f8d52643a4d4bb7bf535927d9dd9d1622be7..4430ddb21916de3e9176bc1273fb4d048835f883 100644 (file)
@@ -27,7 +27,7 @@ COPY requirements_all.txt .
 RUN set -x \
     && pip install --upgrade pip \
     && pip install build maturin \
-    && pip wheel -r requirements_all.txt --find-links "https://wheels.home-assistant.io/musllinux/"
+    && pip wheel -r requirements_all.txt
 
 # build music assistant wheel
 COPY music_assistant music_assistant
@@ -46,6 +46,7 @@ WORKDIR /app
 RUN set -x \
     && apt-get update \
     && apt-get install -y --no-install-recommends \
+        ca-certificates \
         curl \
         git \
         wget \
@@ -78,10 +79,6 @@ LABEL \
     io.hass.platform="${TARGETPLATFORM}" \
     io.hass.type="addon"
 
-EXPOSE 8095/tcp
-EXPOSE 9090/tcp
-EXPOSE 3483/tcp
-
 VOLUME [ "/data" ]
 
 ENTRYPOINT ["mass", "--config", "/data"]
index 9f2399726c4603fdaa78776a28be2044b68343cb..917c4574233e1d935c4d1f9936d2608b2a65fa68 100644 (file)
@@ -1,5 +1,6 @@
 """Helpers to work with (de)serializing of json."""
 
+import asyncio
 import base64
 from types import MethodType
 from typing import Any
@@ -11,44 +12,59 @@ from _collections_abc import dict_keys, dict_values
 JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError)
 JSON_DECODE_EXCEPTIONS = (orjson.JSONDecodeError,)
 
+DO_NOT_SERIALIZE_TYPES = (MethodType, asyncio.Task)
 
-def json_encoder_default(obj: Any) -> Any:
-    """Convert Special objects.
 
-    Hand other objects to the original method.
-    """
+def get_serializable_value(obj: Any, raise_unhandled: bool = False) -> Any:
+    """Parse the value to its serializable equivalent."""
     if getattr(obj, "do_not_serialize", None):
         return None
     if (
         isinstance(obj, list | set | filter | tuple | dict_values | dict_keys | dict_values)
         or obj.__class__ == "dict_valueiterator"
     ):
-        return list(obj)
-    if hasattr(obj, "as_dict"):
-        return obj.as_dict()
+        return [get_serializable_value(x) for x in obj]
     if hasattr(obj, "to_dict"):
-        return obj.to_dict(omit_none=True)
+        return obj.to_dict()
     if isinstance(obj, bytes):
         return base64.b64encode(obj).decode("ascii")
-    if isinstance(obj, MethodType):
+    if isinstance(obj, DO_NOT_SERIALIZE_TYPES):
         return None
-    raise TypeError
+    if raise_unhandled:
+        raise TypeError()
+    return obj
 
 
-def json_dumps(data: Any) -> str:
+def serialize_to_json(obj: Any) -> Any:
+    """Serialize a value (or a list of values) to json."""
+    if obj is None:
+        return obj
+    if hasattr(obj, "to_json"):
+        return obj.to_json()
+    return json_dumps(get_serializable_value(obj))
+
+
+def json_dumps(data: Any, indent: bool = False) -> str:
     """Dump json string."""
+    # we use the passthrough dataclass option because we use mashumaro for that
+    option = orjson.OPT_OMIT_MICROSECONDS | orjson.OPT_PASSTHROUGH_DATACLASS
+    if indent:
+        option |= orjson.OPT_INDENT_2
     return orjson.dumps(
         data,
-        option=orjson.OPT_NON_STR_KEYS | orjson.OPT_INDENT_2,
-        default=json_encoder_default,
+        default=get_serializable_value,
+        option=option,
     ).decode("utf-8")
 
 
 json_loads = orjson.loads
 
 
-async def load_json_file(path: str) -> dict:
+async def load_json_file(path: str, target_class: type | None = None) -> dict:
     """Load JSON from file."""
     async with aiofiles.open(path, "r") as _file:
         content = await _file.read()
+        if target_class:
+            # support for a mashumaro model
+            return target_class.from_json(content)
         return json_loads(content)
index 1bd85af9af77cd459fc369ef830f32e967aad351..e20a077f9b6be852fb1bd8f6b3cbfc170267aa9c 100644 (file)
@@ -2,16 +2,17 @@
 
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Any
 
-from mashumaro import DataClassDictMixin
+from mashumaro.mixins.orjson import DataClassORJSONMixin
 
+from music_assistant.common.helpers.json import get_serializable_value
 from music_assistant.common.models.event import MassEvent
 
 
 @dataclass
-class CommandMessage(DataClassDictMixin):
+class CommandMessage(DataClassORJSONMixin):
     """Model for a Message holding a command from server to client or client to server."""
 
     message_id: str | int
@@ -20,7 +21,7 @@ class CommandMessage(DataClassDictMixin):
 
 
 @dataclass
-class ResultMessageBase(DataClassDictMixin):
+class ResultMessageBase(DataClassORJSONMixin):
     """Base class for a result/response of a Command Message."""
 
     message_id: str
@@ -30,7 +31,7 @@ class ResultMessageBase(DataClassDictMixin):
 class SuccessResultMessage(ResultMessageBase):
     """Message sent when a Command has been successfully executed."""
 
-    result: Any
+    result: Any = field(default=None, metadata={"serialize": lambda v: get_serializable_value(v)})
 
 
 @dataclass
@@ -41,11 +42,12 @@ class ErrorResultMessage(ResultMessageBase):
     details: str | None = None
 
 
+# EventMessage is the same as MassEvent, this is just a alias.
 EventMessage = MassEvent
 
 
 @dataclass
-class ServerInfoMessage(DataClassDictMixin):
+class ServerInfoMessage(DataClassORJSONMixin):
     """Message sent by the server with it's info when a client connects."""
 
     server_version: str
@@ -53,9 +55,5 @@ class ServerInfoMessage(DataClassDictMixin):
 
 
 MessageType = (
-    CommandMessage
-    | EventMessage
-    | SuccessResultMessage
-    | ErrorResultMessage
-    | ServerInfoMessage
+    CommandMessage | EventMessage | SuccessResultMessage | ErrorResultMessage | ServerInfoMessage
 )
index 0c4710781d20ddc40efa03cdedfa44ddcc8f3a8c..23afcd955da381a948227f01d61b0fcc94585680 100644 (file)
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 import logging
-from collections.abc import Callable, Iterable
+from collections.abc import Iterable
 from dataclasses import dataclass
 from types import NoneType
 from typing import Any
@@ -18,18 +18,22 @@ from music_assistant.constants import (
     CONF_OUTPUT_CHANNELS,
     CONF_VOLUME_NORMALISATION,
     CONF_VOLUME_NORMALISATION_TARGET,
+    SECURE_STRING_SUBSTITUTE,
 )
 
 from .enums import ConfigEntryType
 
 LOGGER = logging.getLogger(__name__)
 
+ENCRYPT_CALLBACK: callable[[str], str] | None = None
+DECRYPT_CALLBACK: callable[[str], str] | None = None
+
 ConfigValueType = str | int | float | bool | None
 
 ConfigEntryTypeMap = {
     ConfigEntryType.BOOLEAN: bool,
     ConfigEntryType.STRING: str,
-    ConfigEntryType.PASSWORD: str,
+    ConfigEntryType.SECURE_STRING: str,
     ConfigEntryType.INTEGER: int,
     ConfigEntryType.FLOAT: float,
     ConfigEntryType.LABEL: str,
@@ -75,6 +79,8 @@ class ConfigEntry(DataClassDictMixin):
     hidden: bool = False
     # advanced: this is an advanced setting (frontend hides it in some corner)
     advanced: bool = False
+    # encrypt: store string value encrypted and do not send its value in the api
+    encrypt: bool = False
 
 
 @dataclass
@@ -131,9 +137,9 @@ class Config(DataClassDictMixin):
     def get_value(self, key: str) -> ConfigValueType:
         """Return config value for given key."""
         config_value = self.values[key]
-        if config_value.type == ConfigEntryType.PASSWORD:  # noqa: SIM102
-            if decrypt_callback := self.get_decrypt_callback():
-                return decrypt_callback(config_value.value)
+        if config_value.type == ConfigEntryType.SECURE_STRING:
+            assert DECRYPT_CALLBACK is not None
+            return DECRYPT_CALLBACK(config_value.value)
         return config_value.value
 
     @classmethod
@@ -142,7 +148,6 @@ class Config(DataClassDictMixin):
         config_entries: Iterable[ConfigEntry],
         raw: dict[str, Any],
         allow_none: bool = False,
-        decrypt_callback: Callable[[str], str] | None = None,
     ) -> Config:
         """Parse Config from the raw values (as stored in persistent storage)."""
         values = {
@@ -150,24 +155,57 @@ class Config(DataClassDictMixin):
             for x in config_entries
         }
         conf = cls.from_dict({**raw, "values": values})
-        if decrypt_callback:
-            conf.set_decrypt_callback(decrypt_callback)
         return conf
 
     def to_raw(self) -> dict[str, Any]:
         """Return minimized/raw dict to store in persistent storage."""
+
+        def _handle_value(value: ConfigEntryValue):
+            if value.type == ConfigEntryType.SECURE_STRING:
+                assert ENCRYPT_CALLBACK is not None
+                return ENCRYPT_CALLBACK(value.value)
+            return value.value
+
         return {
             **self.to_dict(),
-            "values": {x.key: x.value for x in self.values.values() if x.value != x.default_value},
+            "values": {
+                x.key: _handle_value(x) for x in self.values.values() if x.value != x.default_value
+            },
         }
 
-    def set_decrypt_callback(self, callback: Callable[[str], str]) -> None:
-        """Register callback to decrypt (password) strings."""
-        setattr(self, "decrypt_callback", callback)
-
-    def get_decrypt_callback(self) -> Callable[[str], str] | None:
-        """Get optional callback to decrypt (password) strings."""
-        return getattr(self, "decrypt_callback", None)
+    def __post_serialize__(self, d: dict[str, Any]) -> dict[str, Any]:
+        """Adjust dict object after it has been serialized."""
+        for key, value in self.values.items():
+            # drop all password values from the serialized dict
+            # API consumers (including the frontend) are not allowed to retrieve it
+            # (even if its encrypted) but they can only set it.
+            if value.value and value.type == ConfigEntryType.SECURE_STRING:
+                d["values"][key]["value"] = SECURE_STRING_SUBSTITUTE
+        return d
+
+    def update(self, update: ConfigUpdate) -> set[str]:
+        """Update Config with updated values."""
+        changed_keys: set[str] = set()
+
+        # root values (enabled, name)
+        for key in ("enabled", "name"):
+            cur_val = getattr(self, key, None)
+            new_val = getattr(update, key, None)
+            if new_val == cur_val:
+                continue
+            setattr(self, key, new_val)
+            changed_keys.add(key)
+
+        # update values
+        if update.values is not None:
+            for key, new_val in update.values.items():
+                cur_val = self.values[key].value
+                if cur_val == new_val:
+                    continue
+                self.values[key].value = new_val
+                changed_keys.add(f"values.{key}")
+
+        return changed_keys
 
 
 @dataclass
@@ -195,6 +233,15 @@ class PlayerConfig(Config):
     name: str | None = None
 
 
+@dataclass
+class ConfigUpdate(DataClassDictMixin):
+    """Config object to send when updating some/all values through the API."""
+
+    enabled: bool | None = None
+    name: str | None = None
+    values: dict[str, ConfigValueType] | None = None
+
+
 DEFAULT_PLAYER_CONFIG_ENTRIES = (
     ConfigEntry(
         key=CONF_VOLUME_NORMALISATION,
index f12de9c1dae02cd2281b1919ce182b289889f046..297e6bff8976c264efdf5390d665d01304a2bdcd 100644 (file)
@@ -338,7 +338,7 @@ class ConfigEntryType(StrEnum):
 
     BOOLEAN = "boolean"
     STRING = "string"
-    PASSWORD = "password"
+    SECURE_STRING = "secure_string"
     INTEGER = "integer"
     FLOAT = "float"
     LABEL = "label"
index 6eb07de24ff98f1c2f105a8c28fed445bbaa12aa..d61a25a4d93083048848a1faba5bd94998a35ee8 100644 (file)
@@ -1,17 +1,18 @@
 """Model for Music Assistant Event."""
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Any
 
-from mashumaro import DataClassDictMixin
+from mashumaro.mixins.orjson import DataClassORJSONMixin
 
+from music_assistant.common.helpers.json import get_serializable_value
 from music_assistant.common.models.enums import EventType
 
 
 @dataclass
-class MassEvent(DataClassDictMixin):
+class MassEvent(DataClassORJSONMixin):
     """Representation of an Event emitted in/by Music Assistant."""
 
     event: EventType
     object_id: str | None = None  # player_id, queue_id or uri
-    data: Any = None  # optional data (such as the object)
+    data: Any = field(default=None, metadata={"serialize": lambda v: get_serializable_value(v)})
index 82ab314794d056773944964098696c395848c626..80ab604f4b273250f526b8afb23e55a6fb9d46b3 100644 (file)
@@ -2,9 +2,9 @@
 
 import asyncio
 from dataclasses import dataclass, field
-from typing import TypedDict
+from typing import Any, TypedDict
 
-from mashumaro import DataClassDictMixin
+from mashumaro.mixins.orjson import DataClassORJSONMixin
 
 from music_assistant.common.helpers.json import load_json_file
 
@@ -13,7 +13,7 @@ from .enums import MediaType, ProviderFeature, ProviderType
 
 
 @dataclass
-class ProviderManifest(DataClassDictMixin):
+class ProviderManifest(DataClassORJSONMixin):
     """ProviderManifest, details of a provider."""
 
     type: ProviderType
@@ -44,8 +44,7 @@ class ProviderManifest(DataClassDictMixin):
     @classmethod
     async def parse(cls: "ProviderManifest", manifest_file: str) -> "ProviderManifest":
         """Parse ProviderManifest from file."""
-        manifest_dict = await load_json_file(manifest_file)
-        return cls.from_dict(manifest_dict)
+        return await load_json_file(manifest_file, ProviderManifest)
 
 
 class ProviderInstance(TypedDict):
@@ -69,7 +68,11 @@ class SyncTask:
     media_types: tuple[MediaType]
     task: asyncio.Task
 
-    def __post_init__(self):
-        """Execute action after initialization."""
-        # make sure that the task does not get serialized.
-        setattr(self.task, "do_not_serialize", True)
+    def to_dict(self, *args, **kwargs) -> dict[str, Any]:
+        """Return SyncTask as (serializable) dict."""
+        # ruff: noqa:ARG002
+        return {
+            "provider_domain": self.provider_domain,
+            "provider_instance": self.provider_instance,
+            "media_types": [x.value for x in self.media_types],
+        }
index 0d876755d9d9d15e2ba947a44b5cb0aeb7f56982..8dce0145739034edc6bc3f398e9025c8c57b9b62 100755 (executable)
@@ -68,6 +68,8 @@ DB_TABLE_THUMBS: Final[str] = "thumbnails"
 DB_TABLE_PROVIDER_MAPPINGS: Final[str] = "provider_mappings"
 
 # all other
-MASS_LOGO_ONLINE: Final[str] = (
-    "https://github.com/home-assistant/brands/" "raw/master/custom_integrations/mass/icon%402x.png"
-)
+MASS_LOGO_ONLINE: Final[
+    str
+] = "https://github.com/home-assistant/brands/raw/master/custom_integrations/mass/icon%402x.png"
+ENCRYPT_SUFFIX = "_encrypted_"
+SECURE_STRING_SUBSTITUTE = "this_value_is_encrypted"
index ffe4e75644e93a4b08bc3fb3d036259e3d25bbf3..f0d34a51bb8cd4274ced1a8662b6d04f9e8f21d2 100644 (file)
@@ -10,18 +10,24 @@ from uuid import uuid4
 
 import aiofiles
 from aiofiles.os import wrap
-from cryptography.fernet import Fernet
+from cryptography.fernet import Fernet, InvalidToken
 
 from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads
+from music_assistant.common.models import config_entries
 from music_assistant.common.models.config_entries import (
     DEFAULT_PLAYER_CONFIG_ENTRIES,
     ConfigEntryValue,
+    ConfigUpdate,
     PlayerConfig,
     ProviderConfig,
 )
-from music_assistant.common.models.enums import ConfigEntryType, EventType, ProviderType
-from music_assistant.common.models.errors import PlayerUnavailableError, ProviderUnavailableError
-from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID
+from music_assistant.common.models.enums import EventType, ProviderType
+from music_assistant.common.models.errors import (
+    InvalidDataError,
+    PlayerUnavailableError,
+    ProviderUnavailableError,
+)
+from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID, ENCRYPT_SUFFIX
 from music_assistant.server.helpers.api import api_command
 from music_assistant.server.models.player_provider import PlayerProvider
 
@@ -29,8 +35,8 @@ if TYPE_CHECKING:
     from music_assistant.server.server import MusicAssistant
 
 LOGGER = logging.getLogger(__name__)
-DEFAULT_SAVE_DELAY = 30
-ENCRYPT_SUFFIX = "_encrypted_"
+DEFAULT_SAVE_DELAY = 5
+
 
 isfile = wrap(os.path.isfile)
 remove = wrap(os.remove)
@@ -55,9 +61,14 @@ class ConfigController:
         await self._load()
         self.initialized = True
         # create default server ID if needed (also used for encrypting passwords)
-        server_id: str = self.get(CONF_SERVER_ID, uuid4().hex, True)
+        self.set_default(CONF_SERVER_ID, uuid4().hex)
+        server_id: str = self.get(CONF_SERVER_ID)
+        assert server_id
         fernet_key = base64.urlsafe_b64encode(server_id.encode()[:32])
         self._fernet = Fernet(fernet_key)
+        config_entries.ENCRYPT_CALLBACK = self.encrypt_string
+        config_entries.DECRYPT_CALLBACK = self.decrypt_string
+
         LOGGER.debug("Started.")
 
     async def close(self) -> None:
@@ -68,7 +79,7 @@ class ConfigController:
         await self.async_save()
         LOGGER.debug("Stopped.")
 
-    def get(self, key: str, default: Any = None, setdefault: bool = False) -> Any:
+    def get(self, key: str, default: Any = None) -> Any:
         """Get value(s) for a specific key/path in persistent storage."""
         assert self.initialized, "Not yet (async) initialized"
         # we support a multi level hierarchy by providing the key as path,
@@ -78,18 +89,13 @@ class ConfigController:
         for index, subkey in enumerate(subkeys):
             if index == (len(subkeys) - 1):
                 value = parent.get(subkey, default)
-                if value is None and subkey not in parent and setdefault:
-                    parent[subkey] = default
-                    self.save()
                 if value is None:
                     # replace None with default
                     return default
                 return value
             elif subkey not in parent:
                 # requesting subkey from a non existing parent
-                if not setdefault:
-                    return default
-                parent.setdefault(subkey, {})
+                return default
             else:
                 parent = parent[subkey]
         return default
@@ -113,6 +119,13 @@ class ConfigController:
                 parent.setdefault(subkey, {})
                 parent = parent[subkey]
 
+    def set_default(self, key: str, default_value: Any) -> None:
+        """Set default value(s) for a specific key/path in persistent storage."""
+        assert self.initialized, "Not yet (async) initialized"
+        cur_value = self.get(key, "__MISSING__")
+        if cur_value == "__MISSING__":
+            self.set(key, default_value)
+
     def remove(
         self,
         key: str,
@@ -145,11 +158,12 @@ class ConfigController:
             ProviderConfig.parse(
                 prov_entries[prov_conf["domain"]],
                 prov_conf,
-                decrypt_callback=self.decrypt_password,
             )
             for prov_conf in raw_values.values()
             if (provider_type is None or prov_conf["type"] == provider_type)
             and (provider_domain is None or prov_conf["domain"] == provider_domain)
+            # guard for deleted providers
+            and prov_conf["domain"] in prov_entries
         ]
 
     @api_command("config/providers/get")
@@ -162,25 +176,23 @@ class ConfigController:
                 return ProviderConfig.parse(
                     prov.config_entries,
                     raw_conf,
-                    decrypt_callback=self.decrypt_password,
                 )
         raise KeyError(f"No config found for provider id {instance_id}")
 
-    @api_command("config/providers/set")
-    def set_provider_config(self, config: ProviderConfig, skip_reload: bool = False) -> None:
-        """Create or update ProviderConfig."""
-        # encrypt any password values
-        for val in config.values.values():
-            if val.type == ConfigEntryType.PASSWORD:
-                val.value = self.encrypt_password(val.value)
+    @api_command("config/providers/update")
+    def update_provider_config(
+        self, instance_id: str, update: ConfigUpdate, skip_reload: bool = False
+    ) -> None:
+        """Update ProviderConfig."""
+        config = self.get_provider_config(instance_id)
+        changed_keys = config.update(update)
 
-        conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
-        existing = self.get(conf_key)
-        config_dict = config.to_raw()
-        if existing == config_dict:
+        if not changed_keys:
             # no changes
             return
-        self.set(conf_key, config_dict)
+
+        conf_key = f"{CONF_PROVIDERS}/{instance_id}"
+        self.set(conf_key, config.to_raw())
         # (re)load provider
         if not skip_reload:
             updated_config = self.get_provider_config(config.instance_id)
@@ -234,11 +246,11 @@ class ConfigController:
         existing = self.get(conf_key)
         if not existing:
             raise KeyError(f"Provider {instance_id} does not exist")
+        self.remove(conf_key)
         await self.mass.unload_provider(instance_id)
         if existing["type"] == "music":
             # cleanup entries in library
             await self.mass.music.cleanup_provider(instance_id)
-        self.remove(conf_key)
 
     @api_command("config/players")
     def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]:
@@ -309,16 +321,18 @@ class ConfigController:
                 return ConfigEntryValue.parse(entry, conf["values"].get(key))
         raise KeyError(f"ConfigEntry {key} is invalid")
 
-    @api_command("config/players/set")
-    def set_player_config(self, config: PlayerConfig) -> None:
-        """Create or update PlayerConfig."""
-        conf_key = f"{CONF_PLAYERS}/{config.player_id}"
-        existing = self.get(conf_key)
-        config_dict = config.to_raw()
-        if existing == config_dict:
+    @api_command("config/players/update")
+    def update_player_config(self, player_id: str, update: ConfigUpdate) -> None:
+        """Update PlayerConfig."""
+        config = self.get_player_config(player_id)
+        changed_keys = config.update(update)
+
+        if not changed_keys:
             # no changes
             return
-        self.set(conf_key, config_dict)
+
+        conf_key = f"{CONF_PLAYERS}/{player_id}"
+        self.set(conf_key, config.to_raw())
         # send config updated event
         self.mass.signal_event(
             EventType.PLAYER_CONFIG_UPDATED,
@@ -330,6 +344,10 @@ class ConfigController:
             player = self.mass.players.get(config.player_id)
             player.enabled = config.enabled
             self.mass.players.update(config.player_id)
+            # copy playername to find back the playername if its disabled
+            if not config.enabled and not config.name:
+                config.name = player.display_name
+                self.set(conf_key, config.to_raw())
         except PlayerUnavailableError:
             pass
 
@@ -337,7 +355,7 @@ class ConfigController:
         try:
             if provider := self.mass.get_provider(config.provider):
                 assert isinstance(provider, PlayerProvider)
-                provider.on_player_config_changed(config)
+                provider.on_player_config_changed(config, changed_keys)
         except PlayerUnavailableError:
             pass
 
@@ -403,18 +421,21 @@ class ConfigController:
             await rename(self.filename, filename_backup)
 
         async with aiofiles.open(self.filename, "w", encoding="utf-8") as _file:
-            await _file.write(json_dumps(self._data))
+            await _file.write(json_dumps(self._data, indent=True))
         LOGGER.debug("Saved data to persistent storage")
 
-    def encrypt_password(self, str_value: str) -> str:
+    def encrypt_string(self, str_value: str) -> str:
         """Encrypt a (password)string with Fernet."""
         if str_value.startswith(ENCRYPT_SUFFIX):
             return str_value
         return ENCRYPT_SUFFIX + self._fernet.encrypt(str_value.encode()).decode()
 
-    def decrypt_password(self, encrypted_str: str) -> str:
+    def decrypt_string(self, encrypted_str: str) -> str:
         """Decrypt a (password)string with Fernet."""
         if not encrypted_str.startswith(ENCRYPT_SUFFIX):
             return encrypted_str
         encrypted_str = encrypted_str.replace(ENCRYPT_SUFFIX, "")
-        return self._fernet.decrypt(encrypted_str.encode()).decode()
+        try:
+            return self._fernet.decrypt(encrypted_str.encode()).decode()
+        except InvalidToken as err:
+            raise InvalidDataError("Password decryption failed") from err
index 6993f1194ca8b504b6724876004dfe50b845d53b..35f4520a762fd1c749df349a47db889ee6c19aad 100644 (file)
@@ -6,7 +6,7 @@ import contextlib
 from random import choice, random
 from typing import TYPE_CHECKING
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.models.enums import EventType, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
 from music_assistant.common.models.media_items import (
@@ -184,7 +184,7 @@ class AlbumsController(MediaControllerBase[Album]):
                 self.db_table,
                 {
                     **item.to_db_row(),
-                    "artists": json_dumps(album_artists) or None,
+                    "artists": serialize_to_json(album_artists) or None,
                     "sort_artist": sort_artist,
                 },
             )
@@ -235,9 +235,9 @@ class AlbumsController(MediaControllerBase[Album]):
                 "year": item.year or cur_item.year,
                 "upc": item.upc or cur_item.upc,
                 "album_type": album_type,
-                "artists": json_dumps(album_artists) or None,
-                "metadata": json_dumps(metadata),
-                "provider_mappings": json_dumps(provider_mappings),
+                "artists": serialize_to_json(album_artists) or None,
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
                 "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
             },
         )
index 56f10c07e1f7a6554baac9ccec9795d88e76b6a6..000f857bdb756faa62771f4efb84345f5a5351b7 100644 (file)
@@ -8,7 +8,7 @@ from random import choice, random
 from time import time
 from typing import TYPE_CHECKING, Any
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.models.enums import EventType, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
 from music_assistant.common.models.media_items import (
@@ -323,8 +323,8 @@ class ArtistsController(MediaControllerBase[Artist]):
                 "name": item.name if overwrite else cur_item.name,
                 "sort_name": item.sort_name if overwrite else cur_item.sort_name,
                 "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
-                "metadata": json_dumps(metadata),
-                "provider_mappings": json_dumps(provider_mappings),
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
             },
         )
         # update/set provider_mappings table
index 16001278d1e3841d36e7e185e622bf2623259df0..3dd67d16b96a2e49ada6d94bbe4dfede1b6e1cc6 100644 (file)
@@ -8,7 +8,7 @@ from collections.abc import AsyncGenerator
 from time import time
 from typing import TYPE_CHECKING, Generic, TypeVar
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError
 from music_assistant.common.models.media_items import (
@@ -169,7 +169,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         # in 99% of the cases we just return lazy because we want the details as fast as possible
         # only if we really need to wait for the result (e.g. to prevent race conditions), we
         # can set lazy to false and we await to job to complete.
-        add_task = self.mass.create_task(self.add(details))
+        task_id = f"add_{self.media_type.value}.{details.provider}.{details.item_id}"
+        add_task = self.mass.create_task(self.add, details, task_id=task_id)
         if not lazy:
             await add_task
             return add_task.result()
@@ -437,7 +438,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         await self.mass.music.database.update(
             self.db_table,
             match,
-            {"provider_mappings": json_dumps(db_item.provider_mappings)},
+            {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
         )
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
 
index b9bd97f9fff223d74d64e9c36c2b9d56741f9561..1d3a0899efff20ed3697252a05e966c0cb776a5e 100644 (file)
@@ -5,7 +5,7 @@ import random
 from time import time
 from typing import Any
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.helpers.uri import create_uri
 from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature
 from music_assistant.common.models.errors import (
@@ -228,8 +228,8 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 "sort_name": item.sort_name,
                 "owner": item.owner,
                 "is_editable": item.is_editable,
-                "metadata": json_dumps(metadata),
-                "provider_mappings": json_dumps(provider_mappings),
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
             },
         )
         # update/set provider_mappings table
index 093d441ea2c82097e95cbf24d2ad36af4e45b320..2dafdd2e14a3263fdbefb164b83a0de9a75f20e9 100644 (file)
@@ -4,7 +4,7 @@ from __future__ import annotations
 import asyncio
 from time import time
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.models.enums import EventType, MediaType
 from music_assistant.common.models.media_items import Radio, Track
 from music_assistant.constants import DB_TABLE_RADIOS
@@ -121,8 +121,8 @@ class RadioController(MediaControllerBase[Radio]):
                 # always prefer name from updated item here
                 "name": item.name,
                 "sort_name": item.sort_name,
-                "metadata": json_dumps(metadata),
-                "provider_mappings": json_dumps(provider_mappings),
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
             },
         )
         # update/set provider_mappings table
index 4a28840fbfef3eea8a35b84b690a3d818a36de32..ad0c3937a9f9f5dbe6cd8e5822fa5173bfce9437 100644 (file)
@@ -3,7 +3,7 @@ from __future__ import annotations
 
 import asyncio
 
-from music_assistant.common.helpers.json import json_dumps
+from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
 from music_assistant.common.models.media_items import (
@@ -250,8 +250,8 @@ class TracksController(MediaControllerBase[Track]):
                 self.db_table,
                 {
                     **item.to_db_row(),
-                    "artists": json_dumps(track_artists),
-                    "albums": json_dumps(track_albums),
+                    "artists": serialize_to_json(track_artists),
+                    "albums": serialize_to_json(track_albums),
                     "sort_artist": sort_artist,
                     "sort_album": sort_album,
                 },
@@ -293,10 +293,10 @@ class TracksController(MediaControllerBase[Track]):
                 "sort_name": item.sort_name if overwrite else cur_item.sort_name,
                 "version": item.version if overwrite else cur_item.version,
                 "duration": item.duration if overwrite else cur_item.duration,
-                "artists": json_dumps(track_artists),
-                "albums": json_dumps(track_albums),
-                "metadata": json_dumps(metadata),
-                "provider_mappings": json_dumps(provider_mappings),
+                "artists": serialize_to_json(track_artists),
+                "albums": serialize_to_json(track_albums),
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
                 "isrc": item.isrc or cur_item.isrc,
             },
         )
index 2fac4d91115b883938d6edc9cd4fdedc39f9ea65..e0980071e375b281000dbb153e155a36fc27c661 100644 (file)
@@ -414,11 +414,11 @@ class StreamsController:
                     await resp.write(chunk)
                     bytes_streamed += len(chunk)
 
-                    # do not allow the player to prebuffer more than 10 seconds
+                    # do not allow the player to prebuffer more than 30 seconds
                     seconds_streamed = int(bytes_streamed / stream_job.pcm_sample_size)
                     if (
-                        seconds_streamed > 10
-                        and (seconds_streamed - player.corrected_elapsed_time) > 10
+                        seconds_streamed > 30
+                        and (seconds_streamed - player.corrected_elapsed_time) > 30
                     ):
                         await asyncio.sleep(1)
 
index 1dc3ea8904c078ca646583e54dc510fd5cd27a04..8c13604dc22f7f503d03cb0481a5d093d3be8ac3 100644 (file)
@@ -16,7 +16,6 @@ from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, get_args, get_orig
 
 from aiohttp import WSMsgType, web
 
-from music_assistant.common.helpers.json import json_dumps, json_loads
 from music_assistant.common.models.api import (
     CommandMessage,
     ErrorResultMessage,
@@ -187,7 +186,7 @@ class WebsocketClientHandler:
                     self._logger.debug("Received: %s", msg.data)
 
                 try:
-                    command_msg = CommandMessage.from_dict(json_loads(msg.data))
+                    command_msg = CommandMessage.from_json(msg.data)
                 except ValueError:
                     disconnect_warn = f"Received invalid JSON: {msg.data}"
                     break
@@ -278,7 +277,7 @@ class WebsocketClientHandler:
 
         Async friendly.
         """
-        _message = json_dumps(message)
+        _message = message.to_json()
 
         try:
             self._to_write.put_nowait(_message)
index 8ca8204983d4f0d5b733439ea4c3ecffd147f119..6e5439653cf71c6bba8e8b46b6e64a394a5e5073 100644 (file)
@@ -26,7 +26,7 @@ class PlayerProvider(Provider):
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         return tuple()
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:
+    def on_player_config_changed(self, config: PlayerConfig, changed_keys: set[str]) -> None:
         """Call (by config manager) when the configuration of a player changes."""
 
     def on_player_config_removed(self, player_id: str) -> None:
index 0fbd1f9d2634ea1764bc2dfd5ca6346455971b55..da4f76e6d9728df4313aa7b5e2693c1c9ecb4e3f 100644 (file)
@@ -105,11 +105,11 @@ class AirplayProvider(PlayerProvider):
         base_entries = slimproto_prov.get_player_config_entries(player_id)
         return tuple(base_entries + PLAYER_CONFIG_ENTRIES)
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:
+    def on_player_config_changed(self, config: PlayerConfig, changed_keys: set[str]) -> None:
         """Call (by config manager) when the configuration of a player changes."""
         # forward to slimproto too
         slimproto_prov = self.mass.get_provider("slimproto")
-        slimproto_prov.on_player_config_changed(config)
+        slimproto_prov.on_player_config_changed(config, changed_keys)
 
         async def update_config():
             # stop bridge (it will be auto restarted)
@@ -350,6 +350,7 @@ class AirplayProvider(PlayerProvider):
         common_elem.find("codecs").text = "pcm"
         common_elem.find("sample_rate").text = "44100"
         common_elem.find("resample").text = "0"
+        common_elem.find("player_volume").text = "20"
         # get/set all device configs
         for device_elem in xml_root.findall("device"):
             player_id = device_elem.find("mac").text
index f6791efa6048f55ec93883162a8de270a6aee76b..c12450b4e1ac8881e093d43a195ca05df1c84b38 100644 (file)
@@ -42,8 +42,6 @@ if TYPE_CHECKING:
     from pychromecast.controllers.receiver import CastStatus
     from pychromecast.socket_client import ConnectionStatus
 
-    from music_assistant.common.models.config_entries import PlayerConfig
-
 
 PLAYER_CONFIG_ENTRIES = tuple()
 
@@ -101,16 +99,6 @@ class ChromecastProvider(PlayerProvider):
         for castplayer in list(self.castplayers.values()):
             await self._disconnect_chromecast(castplayer)
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:  # noqa: ARG002
-        """Call (by config manager) when the configuration of a player changes."""
-
-        # run discovery to catch any re-enabled players
-        async def restart_discovery():
-            await self.mass.loop.run_in_executor(None, self.browser.stop_discovery)
-            await self.mass.loop.run_in_executor(None, self.browser.start_discovery)
-
-        self.mass.create_task(restart_discovery())
-
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
         castplayer = self.castplayers[player_id]
index bf475cca3bacd6d71c33566d8da58c5900fcc006..c913050d08bb363359e6c08422716d805665f0d6 100644 (file)
@@ -7,7 +7,7 @@
   "config_entries": [
   ],
   "requirements": ["PyChromecast==13.0.4"],
-  "documentation": "",
+  "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1138",
   "multi_instance": false,
   "builtin": false,
   "load_by_default": true
index 125e4a0015c83ce6dbaa7b344be7aa423633e7ed..21df19e2fa88d2a25cdd8fc06e9cfad1aeab4ed4 100644 (file)
@@ -189,7 +189,9 @@ class DLNAPlayerProvider(PlayerProvider):
         self.notify_server = DLNANotifyServer(self.requester, self.mass)
         self.mass.create_task(self._run_discovery())
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:  # noqa: ARG002
+    def on_player_config_changed(
+        self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
+    ) -> None:
         """Call (by config manager) when the configuration of a player changes."""
         # run discovery to catch any re-enabled players
         self.mass.create_task(self._run_discovery())
@@ -386,11 +388,9 @@ class DLNAPlayerProvider(PlayerProvider):
             else:
                 # new player detected, setup our DLNAPlayer wrapper
 
+                # ignore disabled players
                 conf_key = f"{CONF_PLAYERS}/{udn}/enabled"
-                # disable sonos players by default in dlna provider to
-                # prevent duplicate with sonos provider
-                enabled_by_default = "rincon" not in udn.lower()
-                enabled = self.mass.config.get(conf_key, default=enabled_by_default)
+                enabled = self.mass.config.get(conf_key)
                 if not enabled:
                     self.logger.debug("Ignoring disabled player: %s", udn)
                     return
@@ -411,7 +411,8 @@ class DLNAPlayerProvider(PlayerProvider):
                             address=description_url,
                             manufacturer="unknown",
                         ),
-                        enabled_by_default=enabled_by_default,
+                        # disable sonos players by default in dlna
+                        enabled_by_default="rincon" not in udn.lower(),
                     ),
                     description_url=description_url,
                 )
index a6040467d81ff1eb393a512c54d95f6b3f742884..94501784f2bc46c2a981ab25ee221a30eb6a81f0 100644 (file)
@@ -7,7 +7,7 @@
   "config_entries": [
   ],
   "requirements": ["async-upnp-client==0.33.1", "getmac==0.8.2"],
-  "documentation": "",
+  "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1139",
   "multi_instance": false,
   "builtin": false,
   "load_by_default": true
index 71e0a2c43de28c7148302309b55c13c87f4dbbdd..391a8ff2895746824b08a33201a7337fabadc0ed 100644 (file)
@@ -18,7 +18,7 @@
     },
     {
       "key": "password",
-      "type": "password",
+      "type": "secure_string",
       "label": "Password"
     },
     {
index 202eabead600e3338c7008703ae0f44d57cb976a..e96aba53a640369a6a0b33163fb5bac67a9d5ffb 100644 (file)
@@ -7,7 +7,7 @@
   "config_entries": [
   ],
 
-  "requirements": ["music-assistant-frontend==20230310.0"],
+  "requirements": ["music-assistant-frontend==20230313.0"],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
index 15639271b3a4bfb3dcf09efc9b025cce8281855a..f63c1baaf01a1374f6d1aede7f7dd9cc6e86567a 100644 (file)
@@ -12,7 +12,7 @@
     },
     {
       "key": "password",
-      "type": "password",
+      "type": "secure_string",
       "label": "Password"
     }
   ],
index 0e3e08cddd238cdae19fcd8fe8c4a6e193ccecb7..9e9e8a4470c45812f240b1ba6fb0b7928d1a3163 100644 (file)
@@ -158,7 +158,9 @@ class SlimprotoProvider(PlayerProvider):
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         return SLIM_PLAYER_CONFIG_ENTRIES
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:
+    def on_player_config_changed(
+        self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
+    ) -> None:
         """Call (by config manager) when the configuration of a player changes."""
         # during synced playback this value is requested multiple times a second,
         # so we cache it in a quick lookup dict
@@ -492,7 +494,7 @@ class SlimprotoProvider(PlayerProvider):
             for client in self._socket_clients.values():
                 self._handle_player_update(client)
             # precache player config
-            self.on_player_config_changed(self.mass.config.get_player_config(player_id))
+            self.on_player_config_changed(self.mass.config.get_player_config(player_id), set())
 
     def _handle_disconnected(self, client: SlimClient) -> None:
         """Handle a client disconnected event."""
index a50bef1bc893337c29372d5342153690b0118682..7c8d7dde25bd16fde8d9eca4096ce3e392f77714 100644 (file)
@@ -2,7 +2,7 @@
   "type": "player",
   "domain": "slimproto",
   "name": "Slimproto",
-  "description": "Support for slimproto based players (e.g. squeezebox, squeezelite). Music Assistant emulates a Logitech Media Server.",
+  "description": "Support for slimproto based players (e.g. squeezebox, squeezelite).",
   "codeowners": ["@marcelveldt"],
   "config_entries": [
   ],
index 0f69f6bb800bfae30e328ee2eb276fa755be80c5..8002bddbd8b5ba3fabea8a813530fea431c04444 100644 (file)
@@ -211,7 +211,9 @@ class SonosPlayerProvider(PlayerProvider):
             for player in self.sonosplayers.values():
                 player.soco.end_direct_control_session
 
-    def on_player_config_changed(self, config: PlayerConfig) -> None:  # noqa: ARG002
+    def on_player_config_changed(
+        self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
+    ) -> None:
         """Call (by config manager) when the configuration of a player changes."""
         # run discovery to catch any re-enabled players
         self.mass.create_task(self._run_discovery())
@@ -399,7 +401,7 @@ class SonosPlayerProvider(PlayerProvider):
         """Handle discovered Sonos player."""
         player_id = soco_device.uid
 
-        enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
+        enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled")
         if not enabled:
             self.logger.debug("Ignoring disabled player: %s", player_id)
             return
index a061b61e158b20f5a3ec96ede670dd47009a65e2..31495ba5de00274005e675f82ab9c8c528a470ad 100644 (file)
@@ -12,7 +12,7 @@
     },
     {
       "key": "password",
-      "type": "password",
+      "type": "secure_string",
       "label": "Password"
     }
   ],
index c7ad59334e0b8086a109314fd9f7be836c1c8eec..bbc504fbdd3de6a287bde0e61d8d532d304ac167 100644 (file)
@@ -12,7 +12,7 @@
     },
     {
       "key": "cookie",
-      "type": "string",
+      "type": "secure_string",
       "label": "Cookie"
     }
   ],
index c8083259b3a8e889c6b6a998d7ab69dcada7a9f7..e6455adae93788ddf9735ffa5aa3b6d0f4ee4d32 100644 (file)
@@ -8,6 +8,7 @@ import logging
 import os
 from collections.abc import Awaitable, Callable, Coroutine
 from typing import TYPE_CHECKING, Any
+from uuid import uuid4
 
 from aiohttp import ClientSession, TCPConnector, web
 from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
@@ -15,11 +16,7 @@ from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroc
 from music_assistant.common.helpers.util import get_ip, get_ip_pton, select_free_port
 from music_assistant.common.models.config_entries import ProviderConfig
 from music_assistant.common.models.enums import EventType, ProviderType
-from music_assistant.common.models.errors import (
-    MusicAssistantError,
-    ProviderUnavailableError,
-    SetupFailedError,
-)
+from music_assistant.common.models.errors import ProviderUnavailableError, SetupFailedError
 from music_assistant.common.models.event import MassEvent
 from music_assistant.common.models.provider import ProviderManifest
 from music_assistant.constants import CONF_SERVER_ID, CONF_WEB_IP, ROOT_LOGGER_NAME
@@ -79,7 +76,7 @@ class MusicAssistant:
         self.music = MusicController(self)
         self.players = PlayerController(self)
         self.streams = StreamsController(self)
-        self._tracked_tasks: list[asyncio.Task] = []
+        self._tracked_tasks: dict[str, asyncio.Task] = {}
         self.closing = False
         # register all api commands (methods with decorator)
         self._register_api_commands()
@@ -101,7 +98,8 @@ class MusicAssistant:
         # allow overriding of the base_ip if autodetect failed
         self.base_ip = self.config.get(CONF_WEB_IP, self.base_ip)
         LOGGER.info(
-            "Starting Music Assistant Server on port: %s" " - autodetected IP-address: %s",
+            "Starting Music Assistant Server (%s) on port: %s - autodetected IP-address: %s",
+            self.server_id,
             self.port,
             self.base_ip,
         )
@@ -123,7 +121,7 @@ class MusicAssistant:
         host = None
         self._web_tcp = web.TCPSite(self._web_apprunner, host=host, port=self.port)
         await self._web_tcp.start()
-        await self._setup_discovery()
+        self._setup_discovery()
 
     async def stop(self) -> None:
         """Stop running the music assistant server."""
@@ -131,7 +129,7 @@ class MusicAssistant:
         self.signal_event(EventType.SHUTDOWN)
         self.closing = True
         # cancel all running tasks
-        for task in self._tracked_tasks:
+        for task in self._tracked_tasks.values():
             task.cancel()
         # stop/clean streams controller
         await self.streams.close()
@@ -248,12 +246,16 @@ class MusicAssistant:
         self,
         target: Coroutine | Awaitable | Callable | asyncio.Future,
         *args: Any,
+        task_id: str | None = None,
         **kwargs: Any,
     ) -> asyncio.Task | asyncio.Future:
         """Create Task on (main) event loop from Coroutine(function).
 
         Tasks created by this helper will be properly cancelled on stop.
         """
+        if existing := self._tracked_tasks.get(task_id):
+            # 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))
         elif isinstance(target, asyncio.Future):
@@ -264,20 +266,22 @@ class MusicAssistant:
             # assume normal callable (non coroutine or awaitable)
             task = self.loop.create_task(asyncio.to_thread(target, *args, **kwargs))
 
-        def task_done_callback(*args, **kwargs):  # noqa: ARG001
-            self._tracked_tasks.remove(task)
-            if LOGGER.isEnabledFor(logging.DEBUG):
-                # print unhandled exceptions
-                task_name = getattr(task, "name", "")
-                if not task.cancelled() and task.exception():
-                    task_name = task.get_name() if hasattr(task, "get_name") else task
-                    LOGGER.exception(
-                        "Exception in task %s",
-                        task_name,
-                        exc_info=task.exception(),
-                    )
+        def task_done_callback(_task: asyncio.Future | asyncio.Task):  # noqa: ARG001
+            _task_id = getattr(task, "task_id")
+            self._tracked_tasks.pop(_task_id)
+            # print unhandled exceptions
+            if LOGGER.isEnabledFor(logging.DEBUG) and not _task.cancelled() and _task.exception():
+                task_name = _task.get_name() if hasattr(_task, "get_name") else _task
+                LOGGER.exception(
+                    "Exception in task %s",
+                    task_name,
+                    exc_info=task.exception(),
+                )
 
-        self._tracked_tasks.append(task)
+        if task_id is None:
+            task_id = uuid4().hex
+        setattr(task, "task_id", task_id)
+        self._tracked_tasks[task_id] = task
         task.add_done_callback(task_done_callback)
         return task
 
@@ -341,7 +345,7 @@ class MusicAssistant:
             self._providers[provider.instance_id] = provider
             try:
                 await provider.setup()
-            except MusicAssistantError as err:
+            except Exception as err:
                 provider.last_error = str(err)
                 provider.available = False
                 raise err
@@ -454,35 +458,31 @@ class MusicAssistant:
                         exc_info=exc,
                     )
 
-    async def _setup_discovery(self) -> None:
+    def _setup_discovery(self) -> None:
         """Make this Music Assistant instance discoverable on the network."""
-
-        def setup_discovery():
-            zeroconf_type = "_music-assistant._tcp.local."
-            server_id = "mass"  # TODO ?
-
-            info = ServiceInfo(
-                zeroconf_type,
-                name=f"{server_id}.{zeroconf_type}",
-                addresses=[get_ip_pton()],
-                port=self.port,
-                properties={},
-                server=f"mass_{server_id}.local.",
+        zeroconf_type = "_music-assistant._tcp.local."
+        server_id = self.server_id
+
+        info = ServiceInfo(
+            zeroconf_type,
+            name=f"{server_id}.{zeroconf_type}",
+            addresses=[get_ip_pton()],
+            port=self.port,
+            properties={},
+            server=f"mass_{server_id}.local.",
+        )
+        LOGGER.debug("Starting Zeroconf broadcast...")
+        try:
+            existing = getattr(self, "mass_zc_service_set", None)
+            if existing:
+                self.zeroconf.update_service(info)
+            else:
+                self.zeroconf.register_service(info)
+            setattr(self, "mass_zc_service_set", True)
+        except NonUniqueNameException:
+            LOGGER.error(
+                "Music Assistant instance with identical name present in the local network!"
             )
-            LOGGER.debug("Starting Zeroconf broadcast...")
-            try:
-                existing = getattr(self, "mass_zc_service_set", None)
-                if existing:
-                    self.zeroconf.update_service(info)
-                else:
-                    self.zeroconf.register_service(info)
-                setattr(self, "mass_zc_service_set", True)
-            except NonUniqueNameException:
-                LOGGER.error(
-                    "Music Assistant instance with identical name present in the local network!"
-                )
-
-        await asyncio.to_thread(setup_discovery)
 
     async def __aenter__(self) -> MusicAssistant:
         """Return Context manager."""
index 8bb4196a97fd09d050746774d6b283474f3d5047..2ab1e2f9a11b8e3aa7e6b2a81eedbda844133ddb 100644 (file)
@@ -13,7 +13,7 @@ databases==0.7.0
 getmac==0.8.2
 mashumaro==3.5.0
 memory-tempfile==2.2.3
-music-assistant-frontend==20230310.0
+music-assistant-frontend==20230313.0
 orjson==3.8.6
 pillow==9.4.0
 PyChromecast==13.0.4