"""AirPlay, through pyatv. Caveat worth knowing: AirPlay *mirroring* (the low-latency screen protocol Macs and iPhones use) is closed and has no open sender implementation on Linux. What does work is AirPlay video playback — the receiver is handed a URL and plays it — so the screen goes out as a live HLS stream. That means several seconds of delay, and a receiver that refuses live HLS refuses the cast. pyatv is optional: without it this backend reports itself unavailable instead of taking the whole daemon down. """ from __future__ import annotations import asyncio import contextlib from ..devices import Device from ..util import log, port_open, state_dir from .base import Backend, BackendError INSTALL_HINT = "AirPlay needs pyatv: ~/.local/lib/screencast/venv/bin/pip install pyatv" async def _pingable(host: str) -> bool: proc = await asyncio.create_subprocess_exec( "ping", "-c1", "-W1", host, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL) return await proc.wait() == 0 class AirPlayBackend(Backend): kind = "airplay" containers = ("hls",) progressive = False def __init__(self, daemon) -> None: super().__init__(daemon) self._atv = None self._atv_id = "" self._play_task: asyncio.Task | None = None self._storage = None self._pairing = None self._pairing_device: Device | None = None # ---- pyatv plumbing ---- @staticmethod def available() -> bool: try: import pyatv # noqa: F401 except Exception: # noqa: BLE001 return False return True async def _storage_obj(self): if self._storage is None: from pyatv.storage.file_storage import FileStorage path = state_dir() / "airplay.json" self._storage = FileStorage(str(path), asyncio.get_running_loop()) await self._storage.load() # pyatv writes this with a plain open(); it holds the credentials # that let anyone play to the user's receivers. with contextlib.suppress(OSError): path.touch() path.chmod(0o600) return self._storage async def _config(self, device: Device): """Find the receiver again, and be specific about why when we cannot. Televisions keep announcing `_airplay._tcp` over mDNS while the receiver itself is switched off, so "it is in the list" says nothing about whether it will talk to us. Check the port first: a refused connection is a setting the user can change, and no amount of scanning will fix it. """ import pyatv loop = asyncio.get_running_loop() storage = await self._storage_obj() port = device.port or 7000 if not await port_open(device.host, port): reachable = await port_open(device.host, 80, 1.0) or await _pingable(device.host) log.warning("airplay: %s has nothing listening on %d", device.name, port) raise BackendError( f"{device.name} is on the network but its AirPlay receiver is off — " "turn AirPlay on in the TV's settings" if reachable else f"{device.name} is not answering — switch it on and try again" ) # Unicast first (fast, and exact); some receivers only answer multicast. for attempt, timeout in enumerate((5, 8), start=1): confs = await pyatv.scan(loop, hosts=[device.host], timeout=timeout, storage=storage) if confs: if attempt > 1: log.info("airplay: %s answered on attempt %d", device.name, attempt) return confs[0] log.warning("airplay: %s did not answer a unicast scan (%ds)", device.name, timeout) confs = [c for c in await pyatv.scan(loop, timeout=8, storage=storage) if str(c.address) == device.host] if confs: log.info("airplay: %s only answered the multicast scan", device.name) return confs[0] raise BackendError( f"{device.name} accepts connections but did not identify itself — " "check that AirPlay is on and the TV is awake" ) async def _connect(self, device: Device): if self._atv is not None and self._atv_id == device.id: return self._atv if self._atv is not None: await self.stop(device) # a connection to a different receiver if not self.available(): raise BackendError(INSTALL_HINT) import pyatv conf = await self._config(device) try: self._atv = await pyatv.connect( conf, asyncio.get_running_loop(), storage=await self._storage_obj() ) except Exception as exc: # noqa: BLE001 log.warning("airplay connect to %s failed: %s", device.name, exc) if any(w in str(exc).lower() for w in ("auth", "credential", "pair")): raise BackendError(f"{device.name} needs pairing first") from exc raise BackendError(f"could not connect to {device.name}: {exc}") from exc self._atv_id = device.id log.info("airplay: connected to %s", device.name) return self._atv # ---- casting ---- async def play(self, device: Device, url: str, *, container: str, title: str) -> None: atv = await self._connect(device) async def run(): try: await atv.stream.play_url(url) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 log.warning("airplay playback ended: %s", exc) self.daemon.note_backend_error(f"{device.name}: {exc}") # play_url only returns when playback ends, so it cannot be awaited here. self._play_task = asyncio.create_task(run()) await asyncio.sleep(0.5) if self._play_task.done(): exc = self._play_task.exception() if exc is not None: raise BackendError(f"{device.name} refused the stream: {exc}") log.info("airplay: %s playing %s", device.name, url) async def stop(self, device: Device) -> None: if self._play_task is not None: self._play_task.cancel() self._play_task = None atv, self._atv, self._atv_id = self._atv, None, "" if atv is not None: try: await atv.remote_control.stop() except Exception: # noqa: BLE001 pass atv.close() async def set_volume(self, device: Device, volume: float) -> None: atv = await self._connect(device) await atv.audio.set_volume(max(0.0, min(1.0, volume)) * 100.0) async def transport(self, device: Device, action: str) -> None: atv = await self._connect(device) rc = atv.remote_control fn = {"play": rc.play, "pause": rc.pause, "stop": rc.stop}.get(action) if fn is None: raise BackendError(f"unknown transport action {action}") await fn() async def poll(self, device: Device) -> dict: atv = self._atv if atv is None: return {} out: dict = {"connected": True} try: playing = await atv.metadata.playing() out["playerState"] = str(playing.device_state).split(".")[-1].lower() out["app"] = playing.title or "" except Exception: # noqa: BLE001 pass try: out["volume"] = float(atv.audio.volume) / 100.0 except Exception: # noqa: BLE001 pass return out # ---- pairing (PIN shown on the TV, typed into the panel) ---- async def pair_begin(self, device: Device) -> None: if not self.available(): raise BackendError(INSTALL_HINT) import pyatv from pyatv.const import Protocol await self.pair_cancel() conf = await self._config(device) self._pairing = await pyatv.pair( conf, Protocol.AirPlay, asyncio.get_running_loop(), storage=await self._storage_obj() ) await self._pairing.begin() self._pairing_device = device log.info("airplay: pairing with %s (%s)", device.name, "PIN on the TV" if self._pairing.device_provides_pin else "no PIN needed") if not self._pairing.device_provides_pin: # Some receivers want *us* to show a PIN; nothing to type in that case. await self._pairing.finish() async def pair_pin(self, pin: str) -> bool: if self._pairing is None: raise BackendError("no pairing in progress") name = self._pairing_device.name if self._pairing_device else "the receiver" self._pairing.pin(pin) try: await self._pairing.finish() except Exception as exc: # noqa: BLE001 log.warning("airplay pairing with %s failed: %s", name, exc) await self.pair_cancel() raise BackendError(f"{name} rejected the code: {exc}") from exc ok = bool(self._pairing.has_paired) log.info("airplay: pairing with %s %s", name, "succeeded" if ok else "was refused") if ok and self._storage is not None: await self._storage.save() # Pairing leaves its own connection behind; drop ours so the next cast # reconnects with the credentials we just stored. self._atv, self._atv_id = None, "" await self.pair_cancel() return ok async def pair_cancel(self) -> None: pairing, self._pairing = self._pairing, None self._pairing_device = None if pairing is not None: try: await pairing.close() except Exception: # noqa: BLE001 pass async def close(self) -> None: await self.pair_cancel() if self._atv is not None or self._play_task is not None: await self.stop(Device(id="", kind="airplay", name="", host="", port=0))