[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.
This commit is contained in:
Fabio Manganiello 2024-04-17 03:56:45 +02:00
parent f99f6bdab9
commit e123463804
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774

View file

@ -1,7 +1,12 @@
import threading from typing import Optional
from typing import Callable, 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.backend.http.app.utils import get_remote_base_url
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
@ -35,24 +40,34 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
self._is_local = False self._is_local = False
self.chromecast = chromecast self.chromecast = chromecast
self.chromecasts = {} self._chromecasts_by_uuid = {}
self._chromecasts_by_name = {}
self._media_listeners = {} self._media_listeners = {}
self._refresh_lock = threading.RLock() self._zc = None
self._browser = None
def _get_chromecasts(self, *args, **kwargs): @property
with self._refresh_lock: def zc(self):
ret = pychromecast.get_chromecasts(*args, **kwargs) from zeroconf import Zeroconf
if isinstance(ret, tuple): if not self._zc:
chromecasts, browser = ret self._zc = Zeroconf()
if browser:
browser.stop_discovery()
if browser.zc:
browser.zc.close()
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 @staticmethod
def _get_device_property(cc, prop: str): def _get_device_property(cc, prop: str):
@ -60,7 +75,7 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
return getattr(cc.device, prop) return getattr(cc.device, prop)
return getattr(cc.cast_info, 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. Convert a Chromecast object and its status to a dictionary.
""" """
@ -102,101 +117,23 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
), ),
} }
def _refresh_chromecasts( def _event_callback(self, _, cast: Chromecast):
self, self._chromecasts_by_uuid[cast.uuid] = cast
tries: int = 2, self._chromecasts_by_name[
retry_wait: float = 10, self._get_device_property(cast, 'friendly_name')
timeout: float = 60, ] = cast
blocking: bool = True,
callback: Optional[Callable] = None,
):
"""
Get the list of Chromecast devices
:param tries: Number of retries (default: 2) def get_chromecast(self, chromecast=None):
:param retry_wait: Number of seconds between retries (default: 10 seconds) if isinstance(chromecast, Chromecast):
: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'
return chromecast return chromecast
if not chromecast: if self._chromecasts_by_uuid.get(chromecast):
if not self.chromecast: return self._chromecasts_by_uuid[chromecast]
raise RuntimeError(
'No Chromecast specified nor default Chromecast configured'
)
chromecast = self.chromecast
if chromecast not in self.chromecasts: if self._chromecasts_by_name.get(chromecast):
casts = {} return self._chromecasts_by_name[chromecast]
while n_tries > 0:
n_tries -= 1
casts.update(
{
self._get_device_property(cast, 'friendly_name'): cast
for cast in self._get_chromecasts()
}
)
if chromecast in casts: raise AssertionError(f'Chromecast {chromecast} not found')
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
@action @action
def play( def play(
@ -311,6 +248,17 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
@action @action
def stop(self, *_, chromecast: Optional[str] = None, **__): # type: ignore 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 chromecast = chromecast or self.chromecast
if not chromecast: if not chromecast:
return None return None
@ -513,13 +461,13 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
def _status(self, chromecast: Optional[str] = None) -> dict: def _status(self, chromecast: Optional[str] = None) -> dict:
if chromecast: if chromecast:
assert ( assert (
chromecast in self.chromecasts chromecast in self._chromecasts_by_name
), f'No such Chromecast device: {chromecast}' ), f'No such Chromecast device: {chromecast}'
return self._serialize_device(self.chromecasts[chromecast]) return self._serialize_device(self._chromecasts_by_name[chromecast])
return { return {
name: self._serialize_device(cast) name: self._serialize_device(cast)
for name, cast in self.chromecasts.items() for name, cast in self._chromecasts_by_name.items()
} }
@action @action
@ -633,6 +581,54 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
def remove_subtitles(self, *_, **__): def remove_subtitles(self, *_, **__):
raise NotImplementedError 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): def main(self):
while not self.should_stop(): while not self.should_stop():
try: try: