From: Marcel van der Veldt Date: Wed, 17 Nov 2021 23:20:17 +0000 (+0100) Subject: various fixes for chromecasts and dependency upgrades X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=474dd1803fec21d715f8b35acb678812c5e73986;p=music-assistant-server.git various fixes for chromecasts and dependency upgrades --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index dcf10619..d7eea725 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,6 +1,6 @@ """All constants for Music Assistant.""" -__version__ = "0.2.12" +__version__ = "0.2.13" REQUIRED_PYTHON_VER = "3.8" # configuration keys/attributes diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 41f2d728..0afbd6f6 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -61,10 +61,9 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N """Analyze track audio, for now we only calculate EBU R128 loudness.""" if streamdetails.loudness is not None: - # only when needed we do the analyze stuff + # only when needed we do the analyze job return - # only when needed we do the analyze stuff LOGGER.debug( "Start analyzing track %s/%s", streamdetails.provider, diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 2489c6db..4f7267c7 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -63,7 +63,7 @@ async def get_image_url( and hasattr(item.artist, "metadata") and item.artist.metadata.get("image") ): - return item.album.metadata["image"] + return item.artist.metadata["image"] if media_type == MediaType.TRACK and item.album: # try album instead for tracks return await get_image_url( diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py index 29704b93..0a34e7c3 100644 --- a/music_assistant/helpers/web.py +++ b/music_assistant/helpers/web.py @@ -44,13 +44,15 @@ def serialize_values(obj): isinstance(val, (list, set, filter, tuple)) or val.__class__ == "dict_valueiterator" ): - return [get_val(x) for x in val] + return [get_val(x) for x in val] if val else [] if isinstance(val, dict): return {key: get_val(value) for key, value in val.items()} try: return val.to_dict() except AttributeError: return val + except Exception: + return val return get_val(obj) diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py index 0e179033..06c46451 100755 --- a/music_assistant/managers/config.py +++ b/music_assistant/managers/config.py @@ -36,7 +36,11 @@ from music_assistant.helpers.encryption import _decrypt_string, _encrypt_string from music_assistant.helpers.typing import MusicAssistant from music_assistant.helpers.util import create_task, try_load_json_file from music_assistant.helpers.web import api_route -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType +from music_assistant.models.config_entry import ( + ConfigEntry, + ConfigEntryType, + ConfigValueOption, +) from music_assistant.models.player import PlayerControlType from music_assistant.models.provider import ProviderType from passlib.hash import pbkdf2_sha256 @@ -47,6 +51,11 @@ RESOURCES_DIR = ( LOGGER = logging.getLogger("config_manager") +SAMPLERATE_OPTIONS = [ + ConfigValueOption(text=str(val), value=val) + for val in (41000, 48000, 96000, 176000, 192000, 384000) +] + DEFAULT_PLAYER_CONFIG_ENTRIES = [ ConfigEntry( entry_key=CONF_ENABLED, @@ -64,7 +73,7 @@ DEFAULT_PLAYER_CONFIG_ENTRIES = [ ConfigEntry( entry_key=CONF_MAX_SAMPLE_RATE, entry_type=ConfigEntryType.INT, - values=[41000, 48000, 96000, 176000, 192000, 384000], + options=SAMPLERATE_OPTIONS, default_value=96000, label=CONF_MAX_SAMPLE_RATE, description="desc_sample_rate", @@ -324,7 +333,7 @@ class ConfigManager: self.loading = True conf_file = os.path.join(self.data_path, "config.json") data = try_load_json_file(conf_file) - if not data: + if data is None: # might be a corrupt config file, retry with backup file conf_file_backup = os.path.join(self.data_path, "config.json.backup") data = try_load_json_file(conf_file_backup) @@ -494,7 +503,9 @@ class PlayerSettings(ConfigBaseItem): ) if power_controls: controls = [ - {"text": f"{item.provider}: {item.name}", "value": item.control_id} + ConfigValueOption( + text=f"{item.provider}: {item.name}", value=item.control_id + ) for item in power_controls ] entries.append( @@ -503,7 +514,7 @@ class PlayerSettings(ConfigBaseItem): entry_type=ConfigEntryType.STRING, label=CONF_POWER_CONTROL, description="desc_power_control", - values=controls, + options=controls, ) ) # append volume control config entries @@ -512,7 +523,9 @@ class PlayerSettings(ConfigBaseItem): ) if volume_controls: controls = [ - {"text": f"{item.provider}: {item.name}", "value": item.control_id} + ConfigValueOption( + text=f"{item.provider}: {item.name}", value=item.control_id + ) for item in volume_controls ] entries.append( @@ -521,7 +534,7 @@ class PlayerSettings(ConfigBaseItem): entry_type=ConfigEntryType.STRING, label=CONF_VOLUME_CONTROL, description="desc_volume_control", - values=controls, + options=controls, ) ) # append special group player entries diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py index 14584061..2af08bfa 100644 --- a/music_assistant/models/config_entry.py +++ b/music_assistant/models/config_entry.py @@ -1,8 +1,10 @@ """Model and helpers for Config entries.""" -from dataclasses import dataclass, field +from __future__ import annotations + +from dataclasses import dataclass from enum import Enum -from typing import Any, List, Tuple +from typing import List, Optional, Tuple, Union from mashumaro import DataClassDictMixin @@ -19,20 +21,34 @@ class ConfigEntryType(Enum): DICT = "dict" +ValueTypes = Union[str, int, float, bool, dict, None] + + +@dataclass +class ConfigValueOption(DataClassDictMixin): + """Model for a value with seperated name/value.""" + + text: str + value: ValueTypes + + @dataclass class ConfigEntry(DataClassDictMixin): """Model for a Config Entry.""" entry_key: str entry_type: ConfigEntryType - default_value: Any = "" - values: List[Any] = field(default_factory=list) # select from list of values - range: Tuple[Any] = () # select values within range - label: str = "" # a friendly name for the setting - description: str = "" # extended description of the setting. - help_key: str = "" # key in the translations file + default_value: ValueTypes = None + # options: select from list of possible values/options + options: Optional[List[ConfigValueOption]] = None + range: Optional[Tuple[int, int]] = None # select values within range + label: Optional[str] = None # a friendly name for the setting + description: Optional[str] = None # extended description of the setting. + help_key: Optional[str] = None # key in the translations file multi_value: bool = False # allow multiple values from the list - depends_on: str = "" # needs to be set before this setting shows up in frontend + depends_on: Optional[ + str + ] = None # needs to be set before this setting shows up in frontend hidden: bool = False # hide from UI - value: Any = None # set by the configuration manager + value: ValueTypes = None # set by the configuration manager store_hashed: bool = False # value will be hashed, non reversible diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 6831f274..53bb7a5f 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -514,12 +514,12 @@ class Player: active_queue=active_queue, ) - def to_dict(self) -> dict: - """Return playerstate for compatability with json serializer.""" - return self._calculated_state.to_dict() - def __init__(self, *args, **kwargs) -> None: """Initialize a Player instance.""" self.mass: Optional[MusicAssistant] = None self.added_to_mass = False self._calculated_state = CalculatedPlayerState() + + def to_dict(self): + """Return playerstate for compatability with json serializer.""" + return self._calculated_state.to_dict() diff --git a/music_assistant/models/streamdetails.py b/music_assistant/models/streamdetails.py index 59720ca6..af4ed130 100644 --- a/music_assistant/models/streamdetails.py +++ b/music_assistant/models/streamdetails.py @@ -1,8 +1,8 @@ """Models and helpers for the streamdetails of a MediaItem.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional +from typing import Any, Dict, Optional from mashumaro.serializer.base.dict import DataClassDictMixin from music_assistant.models.media_types import MediaType @@ -56,7 +56,7 @@ class StreamDetails(DataClassDictMixin): path: str content_type: ContentType player_id: str = "" - details: Any = None + details: Dict[str, Any] = field(default_factory=dict) seconds_played: int = 0 gain_correct: float = 0 loudness: Optional[float] = None @@ -64,24 +64,11 @@ class StreamDetails(DataClassDictMixin): bit_depth: Optional[int] = None media_type: MediaType = MediaType.TRACK - def to_dict( - self, - use_bytes: bool = False, - use_enum: bool = False, - use_datetime: bool = False, - **kwargs, - ): - """Handle conversion to dict.""" - return { - "provider": self.provider, - "item_id": self.item_id, - "content_type": self.content_type.value, - "media_type": self.media_type.value, - "sample_rate": self.sample_rate, - "bit_depth": self.bit_depth, - "gain_correct": self.gain_correct, - "seconds_played": self.seconds_played, - } + def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]: + """Exclude internal fields from dict.""" + d.pop("path") + d.pop("details") + return d def __str__(self): """Return pretty printable string of object.""" diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index 653934f8..f88ead9c 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -10,7 +10,7 @@ from music_assistant.models.provider import PlayerProvider from pychromecast.controllers.multizone import MultizoneManager from .const import PROV_ID, PROV_NAME, PROVIDER_CONFIG_ENTRIES -from .helpers import DEFAULT_PORT, ChromecastInfo +from .helpers import ChromecastInfo from .player import ChromecastPlayer LOGGER = logging.getLogger(PROV_ID) @@ -70,14 +70,15 @@ class ChromecastProvider(PlayerProvider): def _discover_chromecast(self, uuid, _): """Discover a Chromecast.""" - device_info = self._browser.devices[uuid] + cast_info: pychromecast.models.CastInfo = self._browser.devices[uuid] info = ChromecastInfo( - services=device_info.services, - uuid=device_info.uuid, - model_name=device_info.model_name, - friendly_name=device_info.friendly_name, - is_audio_group=device_info.port != DEFAULT_PORT, + services=cast_info.services, + uuid=cast_info.uuid, + model_name=cast_info.model_name, + friendly_name=cast_info.friendly_name, + cast_type=cast_info.cast_type, + manufacturer=cast_info.manufacturer, ) if info.uuid is None: @@ -99,7 +100,8 @@ class ChromecastProvider(PlayerProvider): player.set_cast_info(info) create_task(self.mass.players.add_player(player)) - def _remove_chromecast(self, uuid, service, cast_info): + @staticmethod + def _remove_chromecast(uuid, service, cast_info): """Handle zeroconf discovery of a removed chromecast.""" # pylint: disable=unused-argument player_id = str(service[1]) diff --git a/music_assistant/providers/chromecast/helpers.py b/music_assistant/providers/chromecast/helpers.py index 5b8606f4..dcbadd61 100644 --- a/music_assistant/providers/chromecast/helpers.py +++ b/music_assistant/providers/chromecast/helpers.py @@ -5,102 +5,63 @@ from typing import Optional import attr from pychromecast import dial -from pychromecast.const import CAST_MANUFACTURERS +from pychromecast.const import CAST_TYPE_GROUP DEFAULT_PORT = 8009 @attr.s(slots=True, frozen=True) class ChromecastInfo: - """Class to hold all data about a chromecast for creating connections. + """ + Class to hold all data about a chromecast for creating connections. This also has the same attributes as the mDNS fields by zeroconf. """ services: set | None = attr.ib() - uuid: str | None = attr.ib( - converter=attr.converters.optional(str), default=None - ) # always convert UUID to string if not None - _manufacturer = attr.ib(type=Optional[str], default=None) - model_name: str = attr.ib(default="") - friendly_name: str | None = attr.ib(default=None) - is_audio_group = attr.ib(type=Optional[bool], default=False) + uuid: str = attr.ib(converter=attr.converters.optional(str)) + model_name: str = attr.ib() + friendly_name: str = attr.ib() + cast_type: str = attr.ib() + manufacturer: str = attr.ib() is_dynamic_group = attr.ib(type=Optional[bool], default=None) @property - def is_information_complete(self) -> bool: - """Return if all information is filled out.""" - want_dynamic_group = self.is_audio_group - have_dynamic_group = self.is_dynamic_group is not None - have_all_except_dynamic_group = all( - attr.astuple( - self, - filter=attr.filters.exclude( - attr.fields(ChromecastInfo).is_dynamic_group - ), - ) - ) - return have_all_except_dynamic_group and ( - not want_dynamic_group or have_dynamic_group - ) - - @property - def manufacturer(self) -> str | None: - """Return the manufacturer.""" - if self._manufacturer: - return self._manufacturer - if not self.model_name: - return None - return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") + def is_audio_group(self) -> bool: + """Return if the cast is an audio group.""" + return self.cast_type == CAST_TYPE_GROUP def fill_out_missing_chromecast_info(self, zconf) -> ChromecastInfo: - """Return a new ChromecastInfo object with missing attributes filled in. + """ + Return a new ChromecastInfo object with missing attributes filled in. Uses blocking HTTP / HTTPS. """ - if self.is_information_complete: + if not self.is_audio_group or self.is_dynamic_group is not None: # We have all information, no need to check HTTP API. return self # Fill out missing group information via HTTP API. - if self.is_audio_group: - is_dynamic_group = False - http_group_status = None - if self.uuid: - http_group_status = dial.get_multizone_status( - None, - services=self.services, - zconf=zconf, - ) - if http_group_status is not None: - is_dynamic_group = any( - str(g.uuid) == self.uuid - for g in http_group_status.dynamic_groups - ) - - return ChromecastInfo( - services=self.services, - uuid=self.uuid, - friendly_name=self.friendly_name, - model_name=self.model_name, - is_audio_group=True, - is_dynamic_group=is_dynamic_group, - ) - - # Fill out some missing information (friendly_name, uuid) via HTTP dial. - http_device_status = dial.get_device_status( - None, services=self.services, zconf=zconf + is_dynamic_group = False + http_group_status = None + http_group_status = dial.get_multizone_status( + None, + services=self.services, + zconf=zconf, ) - if http_device_status is None: - # HTTP dial didn't give us any new information. - return self + if http_group_status is not None: + is_dynamic_group = any( + str(g.uuid) == self.uuid for g in http_group_status.dynamic_groups + ) return ChromecastInfo( services=self.services, - uuid=(self.uuid or http_device_status.uuid), - friendly_name=(self.friendly_name or http_device_status.friendly_name), - manufacturer=(self.manufacturer or http_device_status.manufacturer), - model_name=(self.model_name or http_device_status.model_name), + uuid=self.uuid, + friendly_name=self.friendly_name, + model_name=self.model_name, + cast_type=self.cast_type, + manufacturer=self.manufacturer, + is_dynamic_group=is_dynamic_group, ) def __str__(self): @@ -109,7 +70,8 @@ class ChromecastInfo: class CastStatusListener: - """Helper class to handle pychromecast status callbacks. + """ + Helper class to handle pychromecast status callbacks. Necessary because a CastDevice entity can create a new socket client and therefore callbacks from multiple chromecast connections can @@ -167,7 +129,8 @@ class CastStatusListener: self._cast_device.multizone_new_media_status(group_uuid, media_status) def invalidate(self): - """Invalidate this status listener. + """ + Invalidate this status listener. All following callbacks won't be forwarded. """ diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 0bad83c2..daddb460 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -1,7 +1,6 @@ """Representation of a Cast device on the network.""" import asyncio import logging -import uuid from typing import List, Optional import pychromecast @@ -39,7 +38,6 @@ class ChromecastPlayer(Player): self._cast_info = cast_info self._player_id = cast_info.uuid - self.services = cast_info.services self._chromecast: Optional[pychromecast.Chromecast] = None self.cast_status = None self.media_status = None @@ -77,13 +75,12 @@ class ChromecastPlayer(Player): ) # Chromecast does not support power so we (ab)use mute instead - if self._chromecast.media_controller.is_active: - return ( - self.cast_status.app_id - in ["705D30C6", self._chromecast.media_controller.app_id] - and not self.cast_status.volume_muted - ) - return not self.cast_status.volume_muted + if self.cast_status.app_id is None: + return not self.cast_status.volume_muted + return ( + self.cast_status.app_id in ["705D30C6", pychromecast.APP_MEDIA_RECEIVER] + and not self.cast_status.volume_muted + ) @property def should_poll(self) -> bool: @@ -151,9 +148,7 @@ class ChromecastPlayer(Player): and self._chromecast and not self._is_speaker_group ): - return [ - str(uuid.UUID(item)) for item in self._chromecast.mz_controller.members - ] + return self._chromecast.mz_controller.members return [] @property @@ -181,12 +176,14 @@ class ChromecastPlayer(Player): None, pychromecast.get_chromecast_from_cast_info, pychromecast.discovery.CastInfo( - self.services, + self._cast_info.services, self._cast_info.uuid, self._cast_info.model_name, self._cast_info.friendly_name, None, None, + self._cast_info.cast_type, + self._cast_info.manufacturer, ), self.mass.zeroconf, ) diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py index 5c5f2dfb..35141d90 100644 --- a/music_assistant/providers/universal_group/__init__.py +++ b/music_assistant/providers/universal_group/__init__.py @@ -6,7 +6,11 @@ from typing import List from music_assistant.helpers.typing import MusicAssistant from music_assistant.helpers.util import create_task -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType +from music_assistant.models.config_entry import ( + ConfigEntry, + ConfigEntryType, + ConfigValueOption, +) from music_assistant.models.player import DeviceInfo, Player, PlayerState from music_assistant.models.provider import PlayerProvider @@ -189,7 +193,7 @@ class GroupPlayer(Player): def __get_config_entries(self): """Return config entries for this group player.""" all_players = [ - {"text": item.name, "value": item.player_id} + ConfigValueOption(text=item.name, value=item.player_id) for item in self.mass.players if item.player_id is not self._player_id ] @@ -199,10 +203,9 @@ class GroupPlayer(Player): # selected_players_ids = [] selected_players = [] for player_id in selected_players_ids: - player = self.mass.players.get_player(player_id) - if player: + if player := self.mass.players.get_player(player_id): selected_players.append( - {"text": player.name, "value": player.player_id} + ConfigValueOption(text=player.name, value=player.player_id) ) default_master = "" if selected_players: @@ -212,7 +215,7 @@ class GroupPlayer(Player): entry_key=CONF_PLAYERS, entry_type=ConfigEntryType.STRING, default_value=[], - values=all_players, + options=all_players, label=CONF_PLAYERS, description="group_player_players_desc", multi_value=True, @@ -221,7 +224,7 @@ class GroupPlayer(Player): entry_key=CONF_MASTER, entry_type=ConfigEntryType.STRING, default_value=default_master, - values=selected_players, + options=selected_players, label=CONF_MASTER, description="group_player_master_desc", multi_value=False,