From: Marcel van der Veldt Date: Tue, 21 Mar 2023 23:53:10 +0000 (+0100) Subject: Add support for Chromecast groups and stereo pairs (#560) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=98f6e1e568d79db2f7839dea01055789ed758a95;p=music-assistant-server.git Add support for Chromecast groups and stereo pairs (#560) * Add support for Chromecast player groups and stereo pairs * use mute only when group is active --- diff --git a/music_assistant/common/models/enums.py b/music_assistant/common/models/enums.py index 297e6bff..fa78b6a4 100644 --- a/music_assistant/common/models/enums.py +++ b/music_assistant/common/models/enums.py @@ -217,10 +217,12 @@ class PlayerType(StrEnum): player: A regular player. group: A (dedicated) group player or playergroup. + stereo_pair: Two speakers playing as one stereo pair. """ PLAYER = "player" GROUP = "group" + STEREO_PAIR = "stereo_pair" class PlayerFeature(StrEnum): diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 057cf9cf..f813b2ed 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -48,6 +48,7 @@ CONF_EQ_TREBLE: Final[str] = "eq_treble" CONF_OUTPUT_CHANNELS: Final[str] = "output_channels" CONF_FLOW_MODE: Final[str] = "flow_mode" CONF_LOG_LEVEL: Final[str] = "log_level" +CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs" # config default values DEFAULT_HOST: Final[str] = "0.0.0.0" diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index ff005a57..f8fe0cee 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -421,7 +421,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): item = await provider.get_item(self.media_type, item_id) if not item: raise MediaNotFoundError( - f"{self.media_type.value}//{item_id} not found on provider {provider_domain_or_instance_id}" # noqa: E501 + f"{self.media_type.value}://{item_id} not found on provider {provider_domain_or_instance_id}" # noqa: E501 ) return item diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index ee9e7003..ec96dfc3 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import contextlib import logging from collections.abc import Iterator from typing import TYPE_CHECKING, cast @@ -503,15 +504,16 @@ class PlayerController: # it is the master in a sync group and thus always present as child player child_players.append(player) for child_id in player.group_childs: - if child_player := self.get(child_id): - if not (not only_powered or child_player.powered): - continue - if not ( - not only_playing - or child_player.state in (PlayerState.PLAYING, PlayerState.PAUSED) - ): - continue - child_players.append(child_player) + with contextlib.suppress(PlayerUnavailableError): + if child_player := self.get(child_id): + if not (not only_powered or child_player.powered): + continue + if not ( + not only_playing + or child_player.state in (PlayerState.PLAYING, PlayerState.PAUSED) + ): + continue + child_players.append(child_player) return child_players async def _poll_players(self) -> None: diff --git a/music_assistant/server/helpers/compare.py b/music_assistant/server/helpers/compare.py index 114eae97..48a8d54e 100644 --- a/music_assistant/server/helpers/compare.py +++ b/music_assistant/server/helpers/compare.py @@ -33,7 +33,7 @@ def compare_strings(str1: str, str2: str, strict: bool = True) -> bool: if str1 is None or str2 is None: return False # return early if total length mismatch - if abs(len(str1) - len(str2)) > 2: + if abs(len(str1) - len(str2)) > 4: return False if not strict: return create_safe_string(str1) == create_safe_string(str2) diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 4872c95d..c127b86c 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -11,19 +11,17 @@ from logging import Logger from typing import TYPE_CHECKING from uuid import UUID -from pychromecast import ( - APP_BUBBLEUPNP, - APP_MEDIA_RECEIVER, - Chromecast, - get_chromecast_from_cast_info, -) +import pychromecast +from pychromecast.controllers.bubbleupnp import BubbleUPNPController from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE from pychromecast.controllers.multizone import MultizoneController, MultizoneManager from pychromecast.discovery import CastBrowser, SimpleCastListener from pychromecast.models import CastInfo from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED +from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueOption from music_assistant.common.models.enums import ( + ConfigEntryType, ContentType, MediaType, PlayerFeature, @@ -33,18 +31,17 @@ from music_assistant.common.models.enums import ( from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty from music_assistant.common.models.player import DeviceInfo, Player from music_assistant.common.models.queue_item import QueueItem -from music_assistant.constants import CONF_PLAYERS, MASS_LOGO_ONLINE -from music_assistant.server.helpers.compare import compare_strings +from music_assistant.constants import CONF_HIDE_GROUP_CHILDS, CONF_PLAYERS, MASS_LOGO_ONLINE from music_assistant.server.models.player_provider import PlayerProvider -from music_assistant.server.providers.chromecast.helpers import CastStatusListener, ChromecastInfo + +from .helpers import CastStatusListener, ChromecastInfo if TYPE_CHECKING: from pychromecast.controllers.media import MediaStatus from pychromecast.controllers.receiver import CastStatus from pychromecast.socket_client import ConnectionStatus - -PLAYER_CONFIG_ENTRIES = tuple() +CONF_ALT_APP = "alt_app" @dataclass @@ -53,14 +50,14 @@ class CastPlayer: player_id: str cast_info: ChromecastInfo - cc: Chromecast + cc: pychromecast.Chromecast player: Player logger: Logger - is_stereo_pair: bool = False status_listener: CastStatusListener | None = None mz_controller: MultizoneController | None = None next_item: str | None = None flow_mode_active: bool = False + active_group: str | None = None class ChromecastProvider(PlayerProvider): @@ -110,6 +107,45 @@ class ChromecastProvider(PlayerProvider): for castplayer in list(self.castplayers.values()): await self._disconnect_chromecast(castplayer) + def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + cast_player = self.castplayers.get(player_id) + entries = ( + ConfigEntry( + key=CONF_ALT_APP, + type=ConfigEntryType.BOOLEAN, + label="Use alternate Media app", + default_value=cast_player + and not cast_player.cast_info.is_audio_group + and cast_player.cast_info.manufacturer == "Google Inc.", + description="Using the BubbleUPNP Media controller for playback improves " + "the playback experience but may not work on non-Google hardware.", + advanced=True, + ), + ) + if ( + cast_player + and cast_player.cast_info.is_audio_group + and not cast_player.cast_info.is_multichannel_group + ): + entries = entries + ( + ConfigEntry( + key=CONF_HIDE_GROUP_CHILDS, + type=ConfigEntryType.STRING, + options=[ + ConfigValueOption("Always", "always"), + ConfigValueOption("Only if the group is active/powered", "active"), + ConfigValueOption("Never", "never"), + ], + default_value="active", + label="Hide playergroup members in UI", + description="Hide the individual player entry for the members of this group " + "in the user interface.", + advanced=True, + ), + ) + return entries + async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" castplayer = self.castplayers[player_id] @@ -181,6 +217,10 @@ class ChromecastProvider(PlayerProvider): async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player.""" castplayer = self.castplayers[player_id] + # handle player that is hidden by active group player, use mute as power + if castplayer.active_group: + await self.cmd_volume_mute(player_id, not powered) + return if powered: await self._launch_app(castplayer) else: @@ -215,7 +255,8 @@ class ChromecastProvider(PlayerProvider): # only update status of media controller if player is on if not castplayer.player.powered: return - + if not castplayer.cc.media_controller.is_active: + return try: await asyncio.to_thread(castplayer.cc.media_controller.update_status) except ConnectionResetError as err: @@ -245,58 +286,64 @@ class ChromecastProvider(PlayerProvider): self.logger.debug("Discovered new or updated chromecast %s", disc_info) castplayer = self.castplayers.get(player_id) - if not castplayer: - cast_info = ChromecastInfo.from_cast_info(disc_info) - cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf) - if cast_info.is_dynamic_group: - self.logger.warning("Discovered a dynamic cast group which will be ignored.") - return - - # Instantiate chromecast object - castplayer = CastPlayer( - player_id, - cast_info=cast_info, - cc=get_chromecast_from_cast_info( - disc_info, - self.mass.zeroconf, - ), - player=Player( - player_id=player_id, - provider=self.domain, - type=PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER, - name=cast_info.friendly_name, - available=False, - powered=False, - device_info=DeviceInfo( - model=cast_info.model_name, - address=cast_info.host, - manufacturer=cast_info.manufacturer, - ), - supported_features=( - PlayerFeature.POWER, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.VOLUME_SET, - ), - max_sample_rate=96000, - ), - logger=self.logger.getChild(cast_info.friendly_name), + if castplayer: + # if player was already added, the player will take care of reconnects itself. + castplayer.cast_info.update(disc_info) + self.mass.loop.call_soon_threadsafe(self.mass.players.update, player_id) + return + # new player discovered + cast_info = ChromecastInfo.from_cast_info(disc_info) + cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf) + if cast_info.is_dynamic_group: + self.logger.debug("Discovered a dynamic cast group which will be ignored.") + return + if cast_info.is_multichannel_child: + self.logger.debug( + "Discovered a passive (multichannel) endpoint which will be ignored." ) - self.castplayers[player_id] = castplayer + return - castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr) - if cast_info.is_audio_group: - mz_controller = MultizoneController(cast_info.uuid) - castplayer.cc.register_handler(mz_controller) - castplayer.mz_controller = mz_controller - castplayer.cc.start() + # Instantiate chromecast object + castplayer = CastPlayer( + player_id, + cast_info=cast_info, + cc=pychromecast.get_chromecast_from_cast_info( + disc_info, + self.mass.zeroconf, + ), + player=Player( + player_id=player_id, + provider=self.domain, + type=PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER, + name=cast_info.friendly_name, + available=False, + powered=False, + device_info=DeviceInfo( + model=cast_info.model_name, + address=f"{cast_info.host}:{cast_info.port}", + manufacturer=cast_info.manufacturer, + ), + supported_features=( + PlayerFeature.POWER, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.VOLUME_SET, + ), + max_sample_rate=96000, + ), + logger=self.logger.getChild(cast_info.friendly_name), + ) + self.castplayers[player_id] = castplayer - self.mass.loop.call_soon_threadsafe( - self.mass.players.register_or_update, castplayer.player - ) + castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr) + if cast_info.is_audio_group: + mz_controller = MultizoneController(cast_info.uuid) + castplayer.cc.register_handler(mz_controller) + castplayer.mz_controller = mz_controller - # if player was already added, the player will take care of reconnects itself. - castplayer.cast_info.update(disc_info) - self.mass.loop.call_soon_threadsafe(self.mass.players.update, player_id) + castplayer.cc.start() + self.mass.loop.call_soon_threadsafe( + self.mass.players.register_or_update, castplayer.player + ) def _on_chromecast_removed(self, uuid, service, cast_info): # noqa: ARG002 """Handle zeroconf discovery of a removed Chromecast.""" @@ -316,21 +363,32 @@ class ChromecastProvider(PlayerProvider): status.volume_level, ) castplayer.player.name = castplayer.cast_info.friendly_name - castplayer.player.powered = status.app_id in ( - "705D30C6", - APP_MEDIA_RECEIVER, - APP_BUBBLEUPNP, - ) - castplayer.is_stereo_pair = ( - castplayer.cast_info.is_audio_group - and castplayer.mz_controller - and castplayer.mz_controller.members - and compare_strings(castplayer.mz_controller.members[0], castplayer.player_id) - ) + if castplayer.active_group: + # use mute as power when group is active + castplayer.player.powered = not status.volume_muted + else: + castplayer.player.powered = ( + castplayer.cc.app_id is not None + and castplayer.cc.app_id != pychromecast.IDLE_APP_ID + ) castplayer.player.volume_level = int(status.volume_level * 100) castplayer.player.volume_muted = status.volume_muted - if castplayer.is_stereo_pair: - castplayer.player.type = PlayerType.PLAYER + + # handle stereo pairs + if castplayer.cast_info.is_multichannel_group: + castplayer.player.type = PlayerType.STEREO_PAIR + castplayer.player.group_childs = [] + # handle cast groups + elif castplayer.cast_info.is_audio_group: + castplayer.player.type = PlayerType.GROUP + castplayer.player.group_childs = [ + str(UUID(x)) for x in castplayer.mz_controller.members + ] + castplayer.player.supported_features = ( + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + ) + # send update to player manager self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus): @@ -387,7 +445,7 @@ class ChromecastProvider(PlayerProvider): castplayer.player.available = new_available castplayer.player.device_info = DeviceInfo( model=castplayer.cast_info.model_name, - address=castplayer.cast_info.host, + address=f"{castplayer.cast_info.host}:{castplayer.cast_info.port}", manufacturer=castplayer.cast_info.manufacturer, ) self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) @@ -397,18 +455,6 @@ class ChromecastProvider(PlayerProvider): group_media_controller = self.mz_mgr.get_multizone_mediacontroller(group_uuid) if not group_media_controller: continue - self.on_multizone_new_media_status( - castplayer, group_uuid, group_media_controller.status - ) - - def on_multizone_new_media_status( - self, castplayer: CastPlayer, group_uuid: UUID, media_status: MediaStatus # noqa: ARG002 - ): - """Handle updates of audio group media status.""" - castplayer.logger.debug("Received multizone media status update") - # self.mz_media_status[group_uuid] = media_status - # self.mz_media_status_received[group_uuid] = dt_util.utcnow() - # self.schedule_update_ha_state() ### Helpers / utils @@ -459,17 +505,39 @@ class ChromecastProvider(PlayerProvider): async def _launch_app(self, castplayer: CastPlayer) -> None: """Launch the default Media Receiver App on a Chromecast.""" event = asyncio.Event() + if use_alt_app := self.mass.config.get_player_config_value( + castplayer.player_id, CONF_ALT_APP + ).value: + app_id = pychromecast.config.APP_BUBBLEUPNP + else: + app_id = pychromecast.config.APP_MEDIA_RECEIVER + + if castplayer.cc.app_id == app_id: + return # already active def launched_callback(): self.mass.loop.call_soon_threadsafe(event.set) def launch(): - # controller = BubbleUPNPController() - # castplayer.cc.register_handler(controller) - # controller.launch(launched_callback) - castplayer.cc.media_controller.launch(launched_callback) + # Quit the previous app before starting splash screen or media player + if castplayer.cc.app_id is not None: + castplayer.cc.quit_app() + # Use BubbleUPNP media receiver app if configured + # which enables a more rich display but does not work on all players + # so its configurable to turn it on/off + if use_alt_app: + castplayer.logger.debug( + "Launching BubbleUPNPController (%s) as active app.", app_id + ) + controller = BubbleUPNPController() + castplayer.cc.register_handler(controller) + controller.launch(launched_callback) + else: + castplayer.logger.debug( + "Launching Default Media Receiver (%s) as active app.", app_id + ) + castplayer.cc.media_controller.launch(launched_callback) - castplayer.logger.debug("Launching BubbleUPNPController as active app.") await self.mass.loop.run_in_executor(None, launch) await event.wait() diff --git a/music_assistant/server/providers/chromecast/helpers.py b/music_assistant/server/providers/chromecast/helpers.py index d31ef26c..53bb55ae 100644 --- a/music_assistant/server/providers/chromecast/helpers.py +++ b/music_assistant/server/providers/chromecast/helpers.py @@ -1,12 +1,16 @@ """Helpers to deal with Cast devices.""" from __future__ import annotations +import urllib.error from dataclasses import dataclass from typing import TYPE_CHECKING, Self from uuid import UUID from pychromecast import dial from pychromecast.const import CAST_TYPE_GROUP +from zeroconf import ServiceInfo + +from music_assistant.constants import CONF_HIDE_GROUP_CHILDS if TYPE_CHECKING: from pychromecast.controllers.media import MediaStatus @@ -37,6 +41,8 @@ class ChromecastInfo: cast_type: str | None = None manufacturer: str | None = None is_dynamic_group: bool | None = None + is_multichannel_group: bool = False # group created for e.g. stereo pair + is_multichannel_child: bool = False # speaker that is part of multichannel setup @property def is_audio_group(self) -> bool: @@ -71,20 +77,42 @@ class ChromecastInfo: self.cast_type = cast_info.cast_type self.manufacturer = cast_info.manufacturer - 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 - # Fill out missing group information via HTTP API. - http_group_status = dial.get_multizone_status( + dynamic_groups, multichannel_groups = get_multizone_info(self.services, zconf) + self.is_dynamic_group = self.uuid in dynamic_groups + if self.uuid in multichannel_groups: + self.is_multichannel_group = True + elif multichannel_groups: + self.is_multichannel_child = True + + +def get_multizone_info(services: list[ServiceInfo], zconf: Zeroconf, timeout=30): + """Get multizone info from eureka endpoint.""" + dynamic_groups: set[str] = set() + multichannel_groups: set[str] = set() + try: + _, status = dial._get_status( + services, + zconf, + "/setup/eureka_info?params=multizone", + True, + timeout, None, - services=self.services, - zconf=zconf, ) - if http_group_status is not None: - self.is_dynamic_group = any( - g.uuid == self.uuid for g in http_group_status.dynamic_groups - ) + if "multizone" in status and "dynamic_groups" in status["multizone"]: + for group in status["multizone"]["dynamic_groups"]: + if udn := group.get("uuid"): + uuid = UUID(udn.replace("-", "")) + dynamic_groups.add(uuid) + + if "multizone" in status and "groups" in status["multizone"]: + for group in status["multizone"]["groups"]: + if group["multichannel_group"] and (udn := group.get("uuid")): + uuid = UUID(udn.replace("-", "")) + multichannel_groups.add(uuid) + except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError): + pass + return (dynamic_groups, multichannel_groups) class CastStatusListener: @@ -123,39 +151,71 @@ class CastStatusListener: def new_cast_status(self, status: CastStatus) -> None: """Handle updated CastStatus.""" - if self._valid: - self.prov.on_new_cast_status(self.castplayer, status) + if not self._valid: + return + self.prov.on_new_cast_status(self.castplayer, status) def new_media_status(self, status: MediaStatus) -> None: """Handle updated MediaStatus.""" - if self._valid: - self.prov.on_new_media_status(self.castplayer, status) + if not self._valid: + return + self.prov.on_new_media_status(self.castplayer, status) def new_connection_status(self, status: ConnectionStatus) -> None: """Handle updated ConnectionStatus.""" - if self._valid: - self.prov.on_new_connection_status(self.castplayer, status) + if not self._valid: + return + self.prov.on_new_connection_status(self.castplayer, status) - @staticmethod - def added_to_multizone(group_uuid): + def added_to_multizone(self, group_uuid): """Handle the cast added to a group.""" - print("##### added_to_multizone: %s" % group_uuid) + self.prov.logger.debug( + "%s is added to multizone: %s", self.castplayer.player.display_name, group_uuid + ) + if group_player := self.prov.castplayers.get(group_uuid): + hide_group_childs = self.prov.mass.config.get_player_config_value( + group_player.player_id, CONF_HIDE_GROUP_CHILDS + ).value + if hide_group_childs == "always": + self.castplayer.player.hidden_by.add(group_uuid) def removed_from_multizone(self, group_uuid): """Handle the cast removed from a group.""" - if self._valid: - # self._cast_device.multizone_new_media_status(group_uuid, None) - print("##### removed_from_multizone: %s" % group_uuid) + if not self._valid: + return + if group_uuid in self.castplayer.player.hidden_by: + self.castplayer.player.hidden_by.remove(group_uuid) + self.prov.logger.debug( + "%s is removed from multizone: %s", self.castplayer.player.display_name, group_uuid + ) def multizone_new_cast_status(self, group_uuid, cast_status): # noqa: ARG002 """Handle reception of a new CastStatus for a group.""" - print("##### multizone_new_cast_status: %s" % group_uuid) + if group_player := self.prov.castplayers.get(group_uuid): + hide_group_childs = self.prov.mass.config.get_player_config_value( + group_uuid, CONF_HIDE_GROUP_CHILDS + ).value + if hide_group_childs == "always": + self.castplayer.player.hidden_by.add(group_uuid) + if group_player.cc.media_controller.is_active: + self.castplayer.active_group = group_uuid + if hide_group_childs == "active": + self.castplayer.player.hidden_by.add(group_uuid) + elif group_uuid == self.castplayer.active_group: + self.castplayer.active_group = None + if hide_group_childs != "always" and group_uuid in self.castplayer.player.hidden_by: + self.castplayer.player.hidden_by.remove(group_uuid) + self.prov.logger.debug( + "%s got new cast status for group: %s", self.castplayer.player.display_name, group_uuid + ) def multizone_new_media_status(self, group_uuid, media_status): # noqa: ARG002 """Handle reception of a new MediaStatus for a group.""" - if self._valid: - # self._cast_device.multizone_new_media_status(group_uuid, media_status) - print("##### multizone_new_media_status: %s" % group_uuid) + if not self._valid: + return + self.prov.logger.debug( + "%s got new media_status for group: %s", self.castplayer.player.display_name, group_uuid + ) def invalidate(self): """