"""Google Cast. pychromecast is a threaded, blocking library; every call into it runs in a worker thread and its status callbacks are marshalled back onto the loop. """ from __future__ import annotations import asyncio import functools from ..devices import Device from ..util import log from .base import Backend, BackendError CONTENT_TYPES = {"webm": "video/webm", "mp4": "video/mp4", "matroska": "video/x-matroska", "mpegts": "video/mp2t"} class CastBackend(Backend): kind = "cast" # WebM/VP8 only. The receiver plays a WebM live stream from any cluster, # with no index and no seeking. GStreamer's mp4mux "streamable" output is # not that: a client joining after the first fragment cannot start it (the # TV sits on a spinner, and ffprobe hangs the same way), and the CMAF muxer # that would fix it lives in gst-plugins-rs, which is not a dependency worth # taking for a second-best path. containers = ("webm",) def __init__(self, daemon) -> None: super().__init__(daemon) self._casts: dict[str, object] = {} async def _cast(self, device: Device): cast = self._casts.get(device.id) if cast is not None and getattr(cast, "socket_client", None) is not None: if not cast.socket_client.stop.is_set(): return cast self._casts.pop(device.id, None) info = device.extra.get("cast_info") if info is None: raise BackendError("the device is no longer on the network") zconf = self.daemon.discovery.zeroconf def connect(): import pychromecast cast = pychromecast.get_chromecast_from_cast_info(info, zconf, tries=1, timeout=5) cast.wait(timeout=12) return cast try: cast = await asyncio.to_thread(connect) except Exception as exc: # noqa: BLE001 - anything from the socket layer raise BackendError(f"could not connect to {device.name}: {exc}") from exc self._casts[device.id] = cast return cast async def play(self, device: Device, url: str, *, container: str, title: str) -> None: cast = await self._cast(device) ctype = CONTENT_TYPES.get(container, "video/webm") def start(): mc = cast.media_controller mc.play_media( url, ctype, title=title, stream_type="LIVE", autoplay=True, metadata={"metadataType": 0, "title": title}, ) mc.block_until_active(timeout=20) try: await asyncio.to_thread(start) except Exception as exc: # noqa: BLE001 raise BackendError(f"{device.name} refused the stream: {exc}") from exc log.info("cast: %s playing %s (%s)", device.name, url, ctype) async def stop(self, device: Device) -> None: cast = self._casts.get(device.id) if cast is None: return def finish(): try: cast.media_controller.stop() except Exception: # noqa: BLE001 pass try: cast.quit_app() except Exception: # noqa: BLE001 pass cast.disconnect() await asyncio.to_thread(finish) self._casts.pop(device.id, None) async def set_volume(self, device: Device, volume: float) -> None: cast = await self._cast(device) await asyncio.to_thread( functools.partial(cast.socket_client.receiver_controller.set_volume, max(0.0, min(1.0, volume))) ) async def set_muted(self, device: Device, muted: bool) -> None: cast = await self._cast(device) await asyncio.to_thread( functools.partial(cast.socket_client.receiver_controller.set_volume_muted, bool(muted)) ) async def transport(self, device: Device, action: str) -> None: cast = await self._cast(device) mc = cast.media_controller fn = {"play": mc.play, "pause": mc.pause, "stop": mc.stop}.get(action) if fn is None: raise BackendError(f"unknown transport action {action}") await asyncio.to_thread(fn) async def poll(self, device: Device) -> dict: cast = self._casts.get(device.id) if cast is None: return {} status = cast.socket_client.receiver_controller.status media = cast.media_controller.status state = (media.player_state or "").lower() if media else "" return { "app": (status.display_name if status else "") or "", "volume": float(status.volume_level) if status else -1.0, "muted": bool(status.volume_muted) if status else False, "playerState": state, "connected": not cast.socket_client.stop.is_set(), } async def close(self) -> None: for cast in list(self._casts.values()): try: await asyncio.to_thread(cast.disconnect) except Exception: # noqa: BLE001 pass self._casts.clear()