From 6736c79362f03cbdf09d6852c1b0ce4520c09771 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 13 Mar 2023 20:16:25 +0100 Subject: [PATCH] Various improvements and fixes (#522) * 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 --- Dockerfile | 7 +- music_assistant/common/helpers/json.py | 44 +++++--- music_assistant/common/models/api.py | 20 ++-- .../common/models/config_entries.py | 79 ++++++++++--- music_assistant/common/models/enums.py | 2 +- music_assistant/common/models/event.py | 9 +- music_assistant/common/models/provider.py | 21 ++-- music_assistant/constants.py | 8 +- music_assistant/server/controllers/config.py | 105 +++++++++++------- .../server/controllers/media/albums.py | 10 +- .../server/controllers/media/artists.py | 6 +- .../server/controllers/media/base.py | 7 +- .../server/controllers/media/playlists.py | 6 +- .../server/controllers/media/radio.py | 6 +- .../server/controllers/media/tracks.py | 14 +-- music_assistant/server/controllers/streams.py | 6 +- music_assistant/server/helpers/api.py | 5 +- .../server/models/player_provider.py | 2 +- .../server/providers/airplay/__init__.py | 5 +- .../server/providers/chromecast/__init__.py | 12 -- .../server/providers/chromecast/manifest.json | 2 +- .../server/providers/dlna/__init__.py | 13 ++- .../server/providers/dlna/manifest.json | 2 +- .../providers/filesystem_smb/manifest.json | 2 +- .../server/providers/frontend/manifest.json | 2 +- .../server/providers/qobuz/manifest.json | 2 +- .../server/providers/slimproto/__init__.py | 6 +- .../server/providers/slimproto/manifest.json | 2 +- .../server/providers/sonos/__init__.py | 6 +- .../server/providers/spotify/manifest.json | 2 +- .../server/providers/ytmusic/manifest.json | 2 +- music_assistant/server/server.py | 100 ++++++++--------- requirements_all.txt | 2 +- 33 files changed, 298 insertions(+), 219 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5110f8d5..4430ddb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/music_assistant/common/helpers/json.py b/music_assistant/common/helpers/json.py index 9f239972..917c4574 100644 --- a/music_assistant/common/helpers/json.py +++ b/music_assistant/common/helpers/json.py @@ -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) diff --git a/music_assistant/common/models/api.py b/music_assistant/common/models/api.py index 1bd85af9..e20a077f 100644 --- a/music_assistant/common/models/api.py +++ b/music_assistant/common/models/api.py @@ -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 ) diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 0c471078..23afcd95 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -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, diff --git a/music_assistant/common/models/enums.py b/music_assistant/common/models/enums.py index f12de9c1..297e6bff 100644 --- a/music_assistant/common/models/enums.py +++ b/music_assistant/common/models/enums.py @@ -338,7 +338,7 @@ class ConfigEntryType(StrEnum): BOOLEAN = "boolean" STRING = "string" - PASSWORD = "password" + SECURE_STRING = "secure_string" INTEGER = "integer" FLOAT = "float" LABEL = "label" diff --git a/music_assistant/common/models/event.py b/music_assistant/common/models/event.py index 6eb07de2..d61a25a4 100644 --- a/music_assistant/common/models/event.py +++ b/music_assistant/common/models/event.py @@ -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)}) diff --git a/music_assistant/common/models/provider.py b/music_assistant/common/models/provider.py index 82ab3147..80ab604f 100644 --- a/music_assistant/common/models/provider.py +++ b/music_assistant/common/models/provider.py @@ -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], + } diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 0d876755..8dce0145 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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" diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index ffe4e756..f0d34a51 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -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 diff --git a/music_assistant/server/controllers/media/albums.py b/music_assistant/server/controllers/media/albums.py index 6993f119..35f4520a 100644 --- a/music_assistant/server/controllers/media/albums.py +++ b/music_assistant/server/controllers/media/albums.py @@ -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, }, ) diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index 56f10c07..000f857b 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -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 diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index 16001278..3dd67d16 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -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) diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index b9bd97f9..1d3a0899 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -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 diff --git a/music_assistant/server/controllers/media/radio.py b/music_assistant/server/controllers/media/radio.py index 093d441e..2dafdd2e 100644 --- a/music_assistant/server/controllers/media/radio.py +++ b/music_assistant/server/controllers/media/radio.py @@ -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 diff --git a/music_assistant/server/controllers/media/tracks.py b/music_assistant/server/controllers/media/tracks.py index 4a28840f..ad0c3937 100644 --- a/music_assistant/server/controllers/media/tracks.py +++ b/music_assistant/server/controllers/media/tracks.py @@ -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, }, ) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 2fac4d91..e0980071 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -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) diff --git a/music_assistant/server/helpers/api.py b/music_assistant/server/helpers/api.py index 1dc3ea89..8c13604d 100644 --- a/music_assistant/server/helpers/api.py +++ b/music_assistant/server/helpers/api.py @@ -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) diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 8ca82049..6e543965 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -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: diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 0fbd1f9d..da4f76e6 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -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 diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index f6791efa..c12450b4 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -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] diff --git a/music_assistant/server/providers/chromecast/manifest.json b/music_assistant/server/providers/chromecast/manifest.json index bf475cca..c913050d 100644 --- a/music_assistant/server/providers/chromecast/manifest.json +++ b/music_assistant/server/providers/chromecast/manifest.json @@ -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 diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 125e4a00..21df19e2 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -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, ) diff --git a/music_assistant/server/providers/dlna/manifest.json b/music_assistant/server/providers/dlna/manifest.json index a6040467..94501784 100644 --- a/music_assistant/server/providers/dlna/manifest.json +++ b/music_assistant/server/providers/dlna/manifest.json @@ -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 diff --git a/music_assistant/server/providers/filesystem_smb/manifest.json b/music_assistant/server/providers/filesystem_smb/manifest.json index 71e0a2c4..391a8ff2 100644 --- a/music_assistant/server/providers/filesystem_smb/manifest.json +++ b/music_assistant/server/providers/filesystem_smb/manifest.json @@ -18,7 +18,7 @@ }, { "key": "password", - "type": "password", + "type": "secure_string", "label": "Password" }, { diff --git a/music_assistant/server/providers/frontend/manifest.json b/music_assistant/server/providers/frontend/manifest.json index 202eabea..e96aba53 100644 --- a/music_assistant/server/providers/frontend/manifest.json +++ b/music_assistant/server/providers/frontend/manifest.json @@ -7,7 +7,7 @@ "config_entries": [ ], - "requirements": ["music-assistant-frontend==20230310.0"], + "requirements": ["music-assistant-frontend==20230313.0"], "documentation": "", "multi_instance": false, "builtin": true, diff --git a/music_assistant/server/providers/qobuz/manifest.json b/music_assistant/server/providers/qobuz/manifest.json index 15639271..f63c1baa 100644 --- a/music_assistant/server/providers/qobuz/manifest.json +++ b/music_assistant/server/providers/qobuz/manifest.json @@ -12,7 +12,7 @@ }, { "key": "password", - "type": "password", + "type": "secure_string", "label": "Password" } ], diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 0e3e08cd..9e9e8a44 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -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.""" diff --git a/music_assistant/server/providers/slimproto/manifest.json b/music_assistant/server/providers/slimproto/manifest.json index a50bef1b..7c8d7dde 100644 --- a/music_assistant/server/providers/slimproto/manifest.json +++ b/music_assistant/server/providers/slimproto/manifest.json @@ -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": [ ], diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 0f69f6bb..8002bddb 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -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 diff --git a/music_assistant/server/providers/spotify/manifest.json b/music_assistant/server/providers/spotify/manifest.json index a061b61e..31495ba5 100644 --- a/music_assistant/server/providers/spotify/manifest.json +++ b/music_assistant/server/providers/spotify/manifest.json @@ -12,7 +12,7 @@ }, { "key": "password", - "type": "password", + "type": "secure_string", "label": "Password" } ], diff --git a/music_assistant/server/providers/ytmusic/manifest.json b/music_assistant/server/providers/ytmusic/manifest.json index c7ad5933..bbc504fb 100644 --- a/music_assistant/server/providers/ytmusic/manifest.json +++ b/music_assistant/server/providers/ytmusic/manifest.json @@ -12,7 +12,7 @@ }, { "key": "cookie", - "type": "string", + "type": "secure_string", "label": "Cookie" } ], diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index c8083259..e6455ada 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -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.""" diff --git a/requirements_all.txt b/requirements_all.txt index 8bb4196a..2ab1e2f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 -- 2.34.1