From e1234638045d3a983363935e0b41977c2abbffc1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 17 Apr 2024 03:56:45 +0200 Subject: [PATCH] [media.chromecast] Refactored implementation. Explicitly use a `CastBrowser` object initialized at plugin boot instead of relying on blocking calls to `pychromecast.get_chromecasts`. 1. It enables better event handling via callbacks instead of synchronously waiting for scan batches. 2. It optimizes resources - only one Zeroconf and one CastBrowser object will be created in the plugin, and destroyed upon stop. 3. No need for separate `get_chromecast`/`_refresh_chromecasts` methods: all the scanning is run continuously, so we can just return the results from the maps. --- .../plugins/media/chromecast/__init__.py | 216 +++++++++--------- 1 file changed, 106 insertions(+), 110 deletions(-) diff --git a/platypush/plugins/media/chromecast/__init__.py b/platypush/plugins/media/chromecast/__init__.py index fcf1a446..8c972707 100644 --- a/platypush/plugins/media/chromecast/__init__.py +++ b/platypush/plugins/media/chromecast/__init__.py @@ -1,7 +1,12 @@ -import threading -from typing import Callable, Optional +from typing import Optional -import pychromecast # type: ignore +from pychromecast import ( + CastBrowser, + Chromecast, + ChromecastConnectionError, + SimpleCastListener, + get_chromecast_from_cast_info, +) from platypush.backend.http.app.utils import get_remote_base_url from platypush.plugins import RunnablePlugin, action @@ -35,24 +40,34 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): self._is_local = False self.chromecast = chromecast - self.chromecasts = {} + self._chromecasts_by_uuid = {} + self._chromecasts_by_name = {} self._media_listeners = {} - self._refresh_lock = threading.RLock() + self._zc = None + self._browser = None - def _get_chromecasts(self, *args, **kwargs): - with self._refresh_lock: - ret = pychromecast.get_chromecasts(*args, **kwargs) + @property + def zc(self): + from zeroconf import Zeroconf - if isinstance(ret, tuple): - chromecasts, browser = ret - if browser: - browser.stop_discovery() - if browser.zc: - browser.zc.close() + if not self._zc: + self._zc = Zeroconf() - return chromecasts + return self._zc - return ret + @property + def browser(self): + if not self._browser: + self._browser = CastBrowser( + SimpleCastListener(self._on_chromecast_discovered), self.zc + ) + + self._browser.start_discovery() + + return self._browser + + def _on_chromecast_discovered(self, _, service: str): + self.logger.info('Discovered Chromecast: %s', service) @staticmethod def _get_device_property(cc, prop: str): @@ -60,7 +75,7 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): return getattr(cc.device, prop) return getattr(cc.cast_info, prop) - def _serialize_device(self, cc: pychromecast.Chromecast) -> dict: + def _serialize_device(self, cc: Chromecast) -> dict: """ Convert a Chromecast object and its status to a dictionary. """ @@ -102,101 +117,23 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): ), } - def _refresh_chromecasts( - self, - tries: int = 2, - retry_wait: float = 10, - timeout: float = 60, - blocking: bool = True, - callback: Optional[Callable] = None, - ): - """ - Get the list of Chromecast devices + def _event_callback(self, _, cast: Chromecast): + self._chromecasts_by_uuid[cast.uuid] = cast + self._chromecasts_by_name[ + self._get_device_property(cast, 'friendly_name') + ] = cast - :param tries: Number of retries (default: 2) - :param retry_wait: Number of seconds between retries (default: 10 seconds) - :param timeout: Timeout before failing the call (default: 60 seconds) - :param blocking: If true, then the function will block until all the - Chromecast devices have been scanned. If false, then the provided - callback function will be invoked when a new device is discovered - :param callback: If blocking is false, then you can provide a callback - function that will be invoked when a new device is discovered - """ - casts = { - self._get_device_property(cast, 'friendly_name'): cast - for cast in self._get_chromecasts( - tries=tries, - retry_wait=retry_wait, - timeout=timeout, - blocking=blocking, - callback=callback, - ) - } - - for name, cast in casts.copy().items(): - if not self.chromecasts.get(name): - self.logger.info('Discovered new Chromecast: %s', name) - self.chromecasts[name] = cast - self._update_listeners(name, cast) - cast.wait() - else: - casts.pop(name) - - for name, cast in casts.items(): - cast.wait() - self.logger.info('Refreshed Chromecast state: %s', name) - - def _event_callback(self, _, cast: pychromecast.Chromecast): - with self._refresh_lock: - self.chromecasts[self._get_device_property(cast, 'friendly_name')] = cast - - def _update_listeners(self, name, cast): - if name not in self._media_listeners: - cast.start() - self._media_listeners[name] = MediaListener( - name=name, cast=cast, callback=self._event_callback - ) - cast.media_controller.register_status_listener(self._media_listeners[name]) - self.logger.debug('Started media listener for %s', name) - - def get_chromecast(self, chromecast=None, n_tries=2): - if isinstance(chromecast, pychromecast.Chromecast): - assert chromecast, 'Invalid Chromecast object' + def get_chromecast(self, chromecast=None): + if isinstance(chromecast, Chromecast): return chromecast - if not chromecast: - if not self.chromecast: - raise RuntimeError( - 'No Chromecast specified nor default Chromecast configured' - ) - chromecast = self.chromecast + if self._chromecasts_by_uuid.get(chromecast): + return self._chromecasts_by_uuid[chromecast] - if chromecast not in self.chromecasts: - casts = {} - while n_tries > 0: - n_tries -= 1 - casts.update( - { - self._get_device_property(cast, 'friendly_name'): cast - for cast in self._get_chromecasts() - } - ) + if self._chromecasts_by_name.get(chromecast): + return self._chromecasts_by_name[chromecast] - if chromecast in casts: - self.chromecasts.update(casts) - break - - if chromecast not in self.chromecasts: - raise RuntimeError(f'Device {chromecast} not found') - - cast = self.chromecasts[chromecast] - - try: - cast.wait() - except Exception as e: - self.logger.warning('Failed to wait Chromecast sync: %s', e) - - return cast + raise AssertionError(f'Chromecast {chromecast} not found') @action def play( @@ -311,6 +248,17 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): @action def stop(self, *_, chromecast: Optional[str] = None, **__): # type: ignore + if self.should_stop(): + if self._zc: + self._zc.close() + self._zc = None + + if self._browser: + self._browser.stop_discovery() + self._browser = None + + return + chromecast = chromecast or self.chromecast if not chromecast: return None @@ -513,13 +461,13 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): def _status(self, chromecast: Optional[str] = None) -> dict: if chromecast: assert ( - chromecast in self.chromecasts + chromecast in self._chromecasts_by_name ), f'No such Chromecast device: {chromecast}' - return self._serialize_device(self.chromecasts[chromecast]) + return self._serialize_device(self._chromecasts_by_name[chromecast]) return { name: self._serialize_device(cast) - for name, cast in self.chromecasts.items() + for name, cast in self._chromecasts_by_name.items() } @action @@ -633,6 +581,54 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): def remove_subtitles(self, *_, **__): raise NotImplementedError + def _refresh_chromecasts(self): + cast_info = {cast.friendly_name: cast for cast in self.browser.devices.values()} + + for info in cast_info.values(): + name = info.friendly_name + if self._chromecasts_by_uuid.get( + info.uuid + ) and self._chromecasts_by_name.get(name): + self.logger.debug('Chromecast %s already connected', name) + continue + + self.logger.info('Started scan for Chromecast %s', name) + + try: + cc = get_chromecast_from_cast_info( + info, + self.browser.zc, + tries=2, + retry_wait=5, + timeout=30, + ) + + self._chromecasts_by_name[cc.name] = cc + except ChromecastConnectionError: + self.logger.warning('Failed to connect to Chromecast %s', info) + continue + + if cc.uuid not in self._chromecasts_by_uuid: + self._chromecasts_by_uuid[cc.uuid] = cc + self.logger.debug('Connecting to Chromecast %s', name) + + if name not in self._media_listeners: + cc.start() + self._media_listeners[name] = MediaListener( + name=name or str(cc.uuid), + cast=cc, + callback=self._event_callback, + ) + + cc.media_controller.register_status_listener( + self._media_listeners[name] + ) + + self.logger.info('Connected to Chromecast %s', name) + + self._chromecasts_by_uuid[cc.uuid] = cc + self._chromecasts_by_name[name] = cc + def main(self): while not self.should_stop(): try: