From 39432ddc4b1598657ebc194009741c9959b0bff0 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 4 Aug 2021 14:47:25 +0200 Subject: [PATCH] fix some issues with chromecasts --- music_assistant/managers/players.py | 15 +- .../providers/chromecast/__init__.py | 89 +++++------ .../chromecast/{models.py => helpers.py} | 146 ++++++++++-------- .../providers/chromecast/player.py | 117 ++++++++------ 4 files changed, 206 insertions(+), 161 deletions(-) rename music_assistant/providers/chromecast/{models.py => helpers.py} (51%) diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py index b9f99e3e..1b2cccac 100755 --- a/music_assistant/managers/players.py +++ b/music_assistant/managers/players.py @@ -306,9 +306,10 @@ class PlayerManager: player = self.get_player(player_id) if not player: raise FileNotFoundError("Player not found %s" % player_id) - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) player_queue = self.get_active_player_queue(player_id) + if player_queue.queue_id != player_id and not player.calculated_state.powered: + # only force player on if its not the actual queue player + await self.cmd_power_on(player_id) # a single item or list of items may be provided if not isinstance(items, list): items = [items] @@ -381,10 +382,12 @@ class PlayerManager: player = self.get_player(player_id) if not player: raise FileNotFoundError("Player not found %s" % player_id) - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) - # load items into the queue + player_queue = self.get_active_player_queue(player_id) + if player_queue.queue_id != player_id and not player.calculated_state.powered: + # only force player on if its not the actual queue player + await self.cmd_power_on(player_id) + # load item into the queue queue_item = player_queue.create_queue_item( item_id=uri, provider="url", name=uri, uri=uri ) @@ -437,6 +440,8 @@ class PlayerManager: player.calculated_state.name, ) return + if player_queue.queue_id != player_id and not player.calculated_state.powered: + # only force player on if its not the actual queue player await self.cmd_power_on(player_id) # snapshot the (active) queue prev_queue_items = player_queue.items diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index e6b0dc26..dcabd589 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -1,7 +1,7 @@ """ChromeCast playerprovider.""" import logging -from typing import List +from typing import List, Optional import pychromecast from music_assistant.helpers.util import create_task @@ -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 .models import ChromecastInfo +from .helpers import DEFAULT_PORT, ChromecastInfo from .player import ChromecastPlayer LOGGER = logging.getLogger(PROV_ID) @@ -29,8 +29,7 @@ class ChromecastProvider(PlayerProvider): def __init__(self, *args, **kwargs): """Initialize.""" self.mz_mgr = MultizoneManager() - self._listener = None - self._browser = None + self._browser: Optional[pychromecast.discovery.CastBrowser] = None super().__init__(*args, **kwargs) @property @@ -50,18 +49,16 @@ class ChromecastProvider(PlayerProvider): async def on_start(self) -> bool: """Handle initialization of the provider based on config.""" - self._listener = pychromecast.CastListener( - self.__chromecast_add_update_callback, - self.__chromecast_remove_callback, - self.__chromecast_add_update_callback, + self._browser = pychromecast.discovery.CastBrowser( + pychromecast.discovery.SimpleCastListener( + add_callback=self._discover_chromecast, + remove_callback=self._remove_chromecast, + update_callback=self._discover_chromecast, + ), + self.mass.zeroconf, ) - - def start_discovery(): - self._browser = pychromecast.discovery.start_discovery( - self._listener, self.mass.zeroconf - ) - - create_task(start_discovery) + # start discovery in executor + create_task(self._browser.start_discovery) return True async def on_stop(self): @@ -69,44 +66,42 @@ class ChromecastProvider(PlayerProvider): if not self._browser: return # stop discovery - create_task(pychromecast.stop_discovery, self._browser) - - def __chromecast_add_update_callback(self, cast_uuid, cast_service_name): - """Handle zeroconf discovery of a new or updated chromecast.""" - # pylint: disable=unused-argument - service = self._listener.services[cast_uuid] - cast_info = ChromecastInfo( - services=service[0], - uuid=service[1], - model_name=service[2], - friendly_name=service[3], - host=service[4], - port=service[5], - ) - cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf) - if cast_info.uuid is None: - return # Discovered chromecast without uuid? - player_id = cast_info.uuid - LOGGER.debug( - "Chromecast discovered: %s (%s)", cast_info.friendly_name, player_id + create_task(self._browser.stop_discovery) + + def _discover_chromecast(self, uuid, _): + """Discover a Chromecast.""" + device_info = 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, ) + + if info.uuid is None: + LOGGER.error("Discovered chromecast without uuid %s", info) + return + + info = info.fill_out_missing_chromecast_info(self.mass.zeroconf) + if info.is_dynamic_group: + LOGGER.warning("Discovered dynamic cast group which will be ignored.") + return + + LOGGER.debug("Discovered new or updated chromecast %s", info) + player_id = str(info.uuid) player = self.mass.players.get_player(player_id) if not player: - # cast players may reappear with new uuid, try to handle that - player = self.mass.players.get_player_by_name( - cast_info.friendly_name, PROV_ID - ) - if not player: - player = ChromecastPlayer(self.mass, cast_info) + player = ChromecastPlayer(self.mass, info) # if player was already added, the player will take care of reconnects itself. - player.set_cast_info(cast_info) + player.set_cast_info(info) create_task(self.mass.players.add_player(player)) - @staticmethod - def __chromecast_remove_callback(cast_uuid, cast_service_name, cast_service): - """Handle a Chromecast removed event.""" + def _remove_chromecast(self, uuid, service, cast_info): + """Handle zeroconf discovery of a removed chromecast.""" # pylint: disable=unused-argument - player_id = str(cast_service[1]) - friendly_name = cast_service[3] + player_id = str(service[1]) + friendly_name = service[3] LOGGER.debug("Chromecast removed: %s - %s", friendly_name, player_id) # we ignore this event completely as the Chromecast socket client handles this itself diff --git a/music_assistant/providers/chromecast/models.py b/music_assistant/providers/chromecast/helpers.py similarity index 51% rename from music_assistant/providers/chromecast/models.py rename to music_assistant/providers/chromecast/helpers.py index a96ae10e..5b8606f4 100644 --- a/music_assistant/providers/chromecast/models.py +++ b/music_assistant/providers/chromecast/helpers.py @@ -1,61 +1,70 @@ -""" -Class to hold all data about a chromecast for creating connections. +"""Helpers to deal with Cast devices.""" +from __future__ import annotations -This also has the same attributes as the mDNS fields by zeroconf. -""" -import logging -from dataclasses import dataclass, field -from typing import Optional, Tuple +from typing import Optional +import attr from pychromecast import dial from pychromecast.const import CAST_MANUFACTURERS -from .const import PROV_ID - -LOGGER = logging.getLogger(PROV_ID) DEFAULT_PORT = 8009 -@dataclass() +@attr.s(slots=True, frozen=True) class ChromecastInfo: """Class to hold all data about a chromecast for creating connections. This also has the same attributes as the mDNS fields by zeroconf. """ - services: Optional[set] = field(default_factory=set) - uuid: Optional[str] = None - model_name: str = "" - friendly_name: Optional[str] = None - is_dynamic_group: bool = False - manufacturer: Optional[str] = None - host: Optional[str] = "" - port: Optional[int] = 0 - _info_requested: bool = field(init=False, default=False) - - def __post_init__(self): - """Convert UUID to string.""" - self.uuid = str(self.uuid) + 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) + is_dynamic_group = attr.ib(type=Optional[bool], default=None) @property - def is_audio_group(self) -> bool: - """Return if this is an audio group.""" - return self.port != DEFAULT_PORT + 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 host_port(self) -> Tuple[str, int]: - """Return the host+port tuple.""" - return self.host, self.port - - def fill_out_missing_chromecast_info(self, zconf) -> None: - """Lookup missing info for the Chromecast player.""" - http_device_status = None - - if self._info_requested: - return + 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 fill_out_missing_chromecast_info(self, zconf) -> ChromecastInfo: + """Return a new ChromecastInfo object with missing attributes filled in. + + Uses blocking HTTP / HTTPS. + """ + if self.is_information_complete: + # 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( @@ -64,29 +73,39 @@ class ChromecastInfo: zconf=zconf, ) if http_group_status is not None: - self.is_dynamic_group = any( + is_dynamic_group = any( str(g.uuid) == self.uuid for g in http_group_status.dynamic_groups ) - else: - # Fill out some missing information (friendly_name, uuid) via HTTP dial. - http_device_status = dial.get_device_status( - None, services=self.services, zconf=zconf - ) - if http_device_status: - self.uuid = str(http_device_status.uuid) - if not self.friendly_name and http_device_status: - self.friendly_name = http_device_status.friendly_name - if not self.model_name and http_device_status: - self.model_name = http_device_status.model_name - if not self.manufacturer and http_device_status: - self.manufacturer = http_device_status.manufacturer - if not self.manufacturer and self.model_name: - self.manufacturer = CAST_MANUFACTURERS.get( - self.model_name.lower(), "Google Inc." + + 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, ) - self._info_requested = True + # Fill out some missing information (friendly_name, uuid) via HTTP dial. + http_device_status = dial.get_device_status( + None, services=self.services, zconf=zconf + ) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return self + + 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), + ) + + def __str__(self): + """Return pretty printable string for logging.""" + return f"{self.friendly_name} ({self.uuid})" class CastStatusListener: @@ -97,19 +116,22 @@ class CastStatusListener: potentially arrive. This class allows invalidating past chromecast objects. """ - def __init__(self, cast_device, chromecast, mz_mgr): + def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False): """Initialize the status listener.""" self._cast_device = cast_device self._uuid = chromecast.uuid self._valid = True self._mz_mgr = mz_mgr + if cast_device._cast_info.is_audio_group: + self._mz_mgr.add_multizone(chromecast) + if mz_only: + return + chromecast.register_status_listener(self) chromecast.socket_client.media_controller.register_status_listener(self) chromecast.register_connection_listener(self) - if cast_device._cast_info.is_audio_group: - self._mz_mgr.add_multizone(chromecast) - else: + if not cast_device._cast_info.is_audio_group: self._mz_mgr.register_listener(chromecast.uuid, self) def new_cast_status(self, cast_status): @@ -127,11 +149,9 @@ class CastStatusListener: if self._valid: self._cast_device.new_connection_status(connection_status) - def added_to_multizone(self, group_uuid): + @staticmethod + def added_to_multizone(group_uuid): """Handle the cast added to a group.""" - LOGGER.debug( - "Player %s is added to group %s", self._cast_device.name, group_uuid - ) def removed_from_multizone(self, group_uuid): """Handle the cast removed from a group.""" diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 78614d98..1a8cf650 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -1,24 +1,24 @@ """Representation of a Cast device on the network.""" +import asyncio import logging import uuid from typing import List, Optional import pychromecast -from asyncio_throttle import Throttler from music_assistant.helpers.compare import compare_strings from music_assistant.helpers.typing import MusicAssistant from music_assistant.helpers.util import create_task, yield_chunks from music_assistant.models.config_entry import ConfigEntry from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState from music_assistant.models.player_queue import QueueItem -from pychromecast.controllers.multizone import MultizoneController +from pychromecast.controllers.multizone import MultizoneController, MultizoneManager from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED, ) from .const import PLAYER_CONFIG_ENTRIES, PROV_ID -from .models import CastStatusListener, ChromecastInfo +from .helpers import CastStatusListener, ChromecastInfo LOGGER = logging.getLogger(PROV_ID) PLAYER_FEATURES = [PlayerFeature.QUEUE] @@ -44,12 +44,10 @@ class ChromecastPlayer(Player): self.cast_status = None self.media_status = None self.media_status_received = None - self.mz_mgr = None - self.mz_manager = None + self.mz_mgr: Optional[MultizoneManager] = None self._available = False self._status_listener: Optional[CastStatusListener] = None self._is_speaker_group = False - self._throttler = Throttler(rate_limit=1, period=0.1) @property def player_id(self) -> str: @@ -73,15 +71,23 @@ class ChromecastPlayer(Player): return False if self.is_group_player: return ( - self.media_status.player_is_playing - or self.media_status.player_is_paused - or self.media_status.player_is_idle + self._chromecast.media_controller.is_active + and self.cast_status.app_id == pychromecast.APP_MEDIA_RECEIVER ) + # return self._chromecast is not None and self._chromecast.is_idle + # return ( + # self._chromecast.media_controller.app_id + # == pychromecast.config.APP_MEDIA_RECEIVER + # ) + # Chromecast does not support power so we (ab)use mute instead - return ( - not self.cast_status.display_name - or self.cast_status.display_name == "Default Media Receiver" - ) and not self.cast_status.volume_muted + 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 @property def should_poll(self) -> bool: @@ -159,7 +165,7 @@ class ChromecastPlayer(Player): """Return deviceinfo.""" return DeviceInfo( model=self._cast_info.model_name, - address=f"{self._cast_info.host}:{self._cast_info.port}", + address=f"{self._chromecast.uri}" if self._chromecast else "", manufacturer=self._cast_info.manufacturer, ) @@ -189,8 +195,7 @@ class ChromecastPlayer(Player): self.mass.zeroconf, ) self._chromecast = chromecast - self.mz_mgr = self.mass.get_provider(PROV_ID).mz_mgr - + self.mz_mgr: MultizoneManager = self.mass.get_provider(PROV_ID).mz_mgr self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) self._available = False self.cast_status = chromecast.status @@ -285,8 +290,8 @@ class ChromecastPlayer(Player): async def cmd_stop(self) -> None: """Send stop command to player.""" - if self._chromecast and self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.quit_app) + if self._chromecast.media_controller: + await self.chromecast_command(self._chromecast.media_controller.stop) async def cmd_play(self) -> None: """Send play command to player.""" @@ -310,12 +315,22 @@ class ChromecastPlayer(Player): async def cmd_power_on(self) -> None: """Send power ON command to player.""" - await self.chromecast_command(self._chromecast.set_volume_muted, False) + if self.is_group_player: + await self.launch_app() + else: + # chromecast has no real poweroff so we (ab)use mute instead + await self.chromecast_command(self._chromecast.set_volume_muted, False) async def cmd_power_off(self) -> None: """Send power OFF command to player.""" - # chromecast has no real poweroff so we send mute instead - await self.chromecast_command(self._chromecast.set_volume_muted, True) + if self.is_group_player or ( + self._chromecast.media_controller.is_active + and self.cast_status.app_id == self._chromecast.media_controller.app_id + ): + await self.chromecast_command(self._chromecast.quit_app) + if not self.is_group_player: + # chromecast has no real poweroff so we (ab)use mute instead + await self.chromecast_command(self._chromecast.set_volume_muted, True) async def cmd_volume_set(self, volume_level: int) -> None: """Send new volume level command to player.""" @@ -339,7 +354,7 @@ class ChromecastPlayer(Player): async def cmd_queue_load(self, queue_items: List[QueueItem]) -> None: """Load (overwrite) queue with new items.""" player_queue = self.mass.players.get_player_queue(self.player_id) - cc_queue_items = self.__create_queue_items(queue_items[:50]) + cc_queue_items = self.__create_queue_items(queue_items[:25]) repeat_enabled = player_queue.use_queue_stream or player_queue.repeat_enabled queuedata = { "type": "QUEUE_LOAD", @@ -347,30 +362,32 @@ class ChromecastPlayer(Player): "shuffle": False, # handled by our queue controller "queueType": "PLAYLIST", "startIndex": 0, # Item index to play after this request or keep same item if undefined - "items": cc_queue_items, # only load 50 tracks at once or the socket will crash + "items": cc_queue_items, # only load 25 tracks at once or the socket will crash } + await self.launch_app() await self.chromecast_command(self.__send_player_queue, queuedata) - if len(queue_items) > 50: - await self.cmd_queue_append(queue_items[51:]) + if len(queue_items) > 25: + await self.cmd_queue_append(queue_items[26:]) async def cmd_queue_append(self, queue_items: List[QueueItem]) -> None: """Append new items at the end of the queue.""" cc_queue_items = self.__create_queue_items(queue_items) - async for chunk in yield_chunks(cc_queue_items, 50): + async for chunk in yield_chunks(cc_queue_items, 25): queuedata = { "type": "QUEUE_INSERT", "insertBefore": None, "items": chunk, } + await self.launch_app() await self.chromecast_command(self.__send_player_queue, queuedata) def __create_queue_items(self, tracks) -> None: """Create list of CC queue items from tracks.""" return [self.__create_queue_item(track) for track in tracks] - def __create_queue_item(self, queue_item: QueueItem): + @staticmethod + def __create_queue_item(queue_item: QueueItem): """Create CC queue item from track info.""" - player_queue = self.mass.players.get_player_queue(self.player_id) return { "opt_itemId": queue_item.queue_item_id, "autoplay": True, @@ -386,7 +403,7 @@ class ChromecastPlayer(Player): "item_id": queue_item.queue_item_id, }, "contentType": "audio/flac", - "streamType": "LIVE" if player_queue.use_queue_stream else "BUFFERED", + "streamType": pychromecast.STREAM_TYPE_BUFFERED, "metadata": { "title": queue_item.name, "artist": "/".join(x.name for x in queue_item.artists), @@ -398,30 +415,38 @@ class ChromecastPlayer(Player): def __send_player_queue(self, queuedata: dict) -> None: """Send new data to the CC queue.""" media_controller = self._chromecast.media_controller - # pylint: disable=protected-access - receiver_ctrl = media_controller._socket_client.receiver_controller + queuedata["mediaSessionId"] = media_controller.status.media_session_id + media_controller.send_message(queuedata, False) - def send_queue(): - """Plays media after chromecast has switched to requested app.""" - queuedata["mediaSessionId"] = media_controller.status.media_session_id - media_controller.send_message(queuedata, False) + async def launch_app(self): + """Launch the default media receiver app and wait until its launched.""" + media_controller = self._chromecast.media_controller + if ( + media_controller.is_active + and self.cast_status.app_id == pychromecast.APP_MEDIA_RECEIVER + and media_controller.status.media_session_id is not None + ): + # already active + return - if not media_controller.status.media_session_id: - receiver_ctrl.launch_app( - media_controller.app_id, - callback_function=send_queue, - ) - else: - send_queue() + event = asyncio.Event() + + def launched(): + self.mass.loop.call_soon_threadsafe(event.set) + + # pylint: disable=protected-access + receiver_ctrl = media_controller._socket_client.receiver_controller + receiver_ctrl.launch_app( + media_controller.app_id, + callback_function=launched, + ) + await event.wait() async def chromecast_command(self, func, *args, **kwargs): """Execute command on Chromecast.""" - # Chromecast socket really doesn't like multiple commands arriving at the same time - # so we apply some throtling. if not self.available: LOGGER.warning( "Player %s is not available, command can't be executed", self.name ) return - async with self._throttler: - create_task(func, *args, **kwargs) + await create_task(func, *args, **kwargs) -- 2.34.1