forked from platypush/platypush
[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:
parent
f99f6bdab9
commit
e123463804
1 changed files with 106 additions and 110 deletions
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue