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
RUN set -x \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
+ ca-certificates \
curl \
git \
wget \
io.hass.platform="${TARGETPLATFORM}" \
io.hass.type="addon"
-EXPOSE 8095/tcp
-EXPOSE 9090/tcp
-EXPOSE 3483/tcp
-
VOLUME [ "/data" ]
ENTRYPOINT ["mass", "--config", "/data"]
"""Helpers to work with (de)serializing of json."""
+import asyncio
import base64
from types import MethodType
from typing import Any
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)
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
@dataclass
-class ResultMessageBase(DataClassDictMixin):
+class ResultMessageBase(DataClassORJSONMixin):
"""Base class for a result/response of a Command Message."""
message_id: str
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
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
MessageType = (
- CommandMessage
- | EventMessage
- | SuccessResultMessage
- | ErrorResultMessage
- | ServerInfoMessage
+ CommandMessage | EventMessage | SuccessResultMessage | ErrorResultMessage | ServerInfoMessage
)
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
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,
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
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
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 = {
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
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,
BOOLEAN = "boolean"
STRING = "string"
- PASSWORD = "password"
+ SECURE_STRING = "secure_string"
INTEGER = "integer"
FLOAT = "float"
LABEL = "label"
"""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)})
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
@dataclass
-class ProviderManifest(DataClassDictMixin):
+class ProviderManifest(DataClassORJSONMixin):
"""ProviderManifest, details of a provider."""
type: ProviderType
@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):
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],
+ }
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"
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
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)
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:
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,
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
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,
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")
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)
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]:
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,
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
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
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
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 (
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,
},
)
"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,
},
)
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 (
"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
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 (
# 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()
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)
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 (
"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
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
# 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
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 (
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,
},
"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,
},
)
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)
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,
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
Async friendly.
"""
- _message = json_dumps(message)
+ _message = message.to_json()
try:
self._to_write.put_nowait(_message)
"""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:
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)
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
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()
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]
"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
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())
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
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,
)
"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
},
{
"key": "password",
- "type": "password",
+ "type": "secure_string",
"label": "Password"
},
{
"config_entries": [
],
- "requirements": ["music-assistant-frontend==20230310.0"],
+ "requirements": ["music-assistant-frontend==20230313.0"],
"documentation": "",
"multi_instance": false,
"builtin": true,
},
{
"key": "password",
- "type": "password",
+ "type": "secure_string",
"label": "Password"
}
],
"""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
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."""
"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": [
],
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())
"""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
},
{
"key": "password",
- "type": "password",
+ "type": "secure_string",
"label": "Password"
}
],
},
{
"key": "cookie",
- "type": "string",
+ "type": "secure_string",
"label": "Cookie"
}
],
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
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
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()
# 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,
)
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."""
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()
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):
# 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
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
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."""
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