"""Minimal client for the Zidoo media player's own HTTP API The AVR only tells HEOS which of its inputs is selected, never what a device plugged into one is actually showing, so a Zidoo's "now playing" has to be asked for directly, over its own control API on port 9529. Its video player only answers getPlayStatus while a video is actually loaded -- with nothing playing, that route does not exist yet, which looks the same here as the box being off or unreachable. Both are simply "nothing to show" rather than an error: this is a nice-to-have on top of an AVR input, not something the rest of the panel depends on. """ import json import urllib.parse import urllib.request class ZidooError(RuntimeError): """The Zidoo did not do what it was asked -- off, unreachable, or with nothing loaded to do it to.""" class ZidooClient: def __init__(self, host, port=9529, timeout=1.5): self.base_url = f"http://{host}:{port}" self.timeout = timeout self._film_for = (None, {}) # (file path, _film()'s answer) last looked up def _get(self, route, **params): """The raw body of a GET, or None if the Zidoo did not answer.""" query = f"?{urllib.parse.urlencode(params)}" if params else "" try: with urllib.request.urlopen(f"{self.base_url}/{route}{query}", timeout=self.timeout) as response: return response.read() except OSError: # URLError, and a timeout mid-read, which urlopen does not wrap return None def _get_json(self, route, **params): body = self._get(route, **params) try: return json.loads(body) if body is not None else None except ValueError: return None def now_playing(self): """{"song", "artist", "image"} for whatever video is loaded, in the same shape a room's now-playing card already expects -- or None. A film has no artist, so that line under the title carries its year instead. "image" is always None: the poster lives on the Zidoo, which the phone cannot load over plain http, so "poster_id" names it instead for app.py to serve. "position_ms" and "duration_ms" come along whenever the length is known, as a room's do. "play_state" is "play" while the film runs (the video's "status" is 1), "pause" otherwise.""" payload = self._get_json("ZidooVideoPlay/getPlayStatus") if not payload or payload.get("status") != 200: return None video = payload.get("video") or {} title = video.get("title") if not title: return None film = self._film(video.get("path")) year = film.get("year") track = {"song": title, "artist": str(year) if year else None, "image": None, "poster_id": film.get("id"), "play_state": "play" if video.get("status") == 1 else "pause"} if video.get("duration"): track["position_ms"] = video.get("currentPosition") or 0 track["duration_ms"] = video["duration"] return track def seek(self, position_ms): """Jump the loaded video to position_ms. Unlike now_playing(), this is something someone asked for, so a Zidoo that does not answer is an error rather than simply nothing to show.""" # "positon" [sic] is the Zidoo's own spelling. payload = self._get_json("ZidooVideoPlay/seekTo", positon=int(position_ms)) if not payload or payload.get("status") != 200: raise ZidooError("The Zidoo did not seek -- is a film still loaded on it?") def _film(self, path): """The poster wall's entry for the film a file belongs to -- its "id" (which is also its poster's) and "year" among the rest -- or {} when the file is not in the library. Looked up once per file rather than on every poll: the answer comes wrapped in the film's whole cast and crew, and does not change halfway through it.""" if not path: return {} if self._film_for[0] != path: payload = self._get_json("ZidooPoster/v2/getAggregationOfFile", path=path) if payload is None: return {} # unreachable for now -- ask again next poll # "type" names the key holding the item itself: "movie" for a # film. Anything else without one simply gets no poster or year. self._film_for = (path, payload.get(payload.get("type")) or {}) return self._film_for[1] def poster(self, poster_id, width=200, height=300): """(bytes, mimetype) for a film's poster, or None. The Zidoo sends no Content-Type, and answers an id it has no poster for with a JSON error instead, so the image is told apart by its first bytes.""" body = self._get("ZidooPoster/getFile/getPoster", id=poster_id, w=width, h=height) if body is None: return None if body.startswith(b"\x89PNG"): return body, "image/png" if body.startswith(b"\xff\xd8"): return body, "image/jpeg" return None