various fixes for chromecasts and dependency upgrades
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 17 Nov 2021 23:20:17 +0000 (00:20 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 17 Nov 2021 23:20:17 +0000 (00:20 +0100)
12 files changed:
music_assistant/constants.py
music_assistant/helpers/audio.py
music_assistant/helpers/images.py
music_assistant/helpers/web.py
music_assistant/managers/config.py
music_assistant/models/config_entry.py
music_assistant/models/player.py
music_assistant/models/streamdetails.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/chromecast/helpers.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/universal_group/__init__.py

index dcf10619ecd903705b7f893b49f3be10e43c7d41..d7eea7256058ee215616ff2d39e0f2c9b3afc6ad 100755 (executable)
@@ -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
index 41f2d72818f36c8c37953d6d27ada77dbe8bb107..0afbd6f64ccaf56a13004d562bfc693584ff9203 100644 (file)
@@ -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,
index 2489c6db5e5d3f03eae60379a566194b32212050..4f7267c79ad9fe1bcb26f71b62af0bb5af65c39a 100644 (file)
@@ -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(
index 29704b93b6e9e7d2a78f809a460cdffabddde4b0..0a34e7c31b585b596b87e962e4e714c3652d9842 100644 (file)
@@ -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)
 
index 0e1790335cd1d6e4d26514f195b2906f57fc7ce5..06c46451b8faa44ab86ccce9289468e8de9d3007 100755 (executable)
@@ -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
index 1458406171fe9792deda5688de8d80c560ccfee4..2af08bfa6f220a5bcfb2c55dc434e743fcfd65fa 100644 (file)
@@ -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
index 6831f2749e5e71cafb882435a68b558415524e6f..53bb7a5f0e84e78743de77e76e3688cce88d5ef2 100755 (executable)
@@ -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()
index 59720ca6d1b345cedec03b340d5bad3c2dae170d..af4ed130a7212765dcf57027f86352fd6d9bd66b 100644 (file)
@@ -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."""
index 653934f8cf4b2f4be1caf8a51af3b3861031bd16..f88ead9c05b26ee942e682bd1a2cc368060edc4b 100644 (file)
@@ -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])
index 5b8606f4b3de94f2ab2aa1c8f7bc21ff2146570f..dcbadd61c96e88d48dcad934c3fb05d98b4f3dc7 100644 (file)
@@ -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.
         """
index 0bad83c28e11b94fb578e5bd26cea88ec4c123c4..daddb460106aaefd2010cce1a73b2efcf28df601 100644 (file)
@@ -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,
         )
index 5c5f2dfb08d0934eceee6b648edcb95a663e766b..35141d90eb0eafb1c09e75d947388e708ed47bcf 100644 (file)
@@ -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,