Files
franzz ec684b7803
Deploy HEOS panel / deploy (push) Successful in 26s
Make progress bar cursor draggable
2026-09-16 23:19:08 +02:00

111 lines
5.0 KiB
Python

"""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