Files
heos/zidoo.py
T
franzz 9ddc13e352
Deploy HEOS panel / deploy (push) Successful in 28s
Add zidoo output info (A/V)
2026-10-20 16:02:20 +02:00

188 lines
9.2 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 re
import urllib.parse
import urllib.request
# What the card has room for, out of the Zidoo's own wording: the longest
# match wins, so "Dolby Digital Plus" is never read as plain "Dolby Digital".
DYNAMIC_RANGES = {"dolby vision": "Dolby Vision", "dovi": "Dolby Vision", "hdr10+": "HDR10+",
"hdr10": "HDR10", "hdr": "HDR", "hlg": "HLG", "sdr": "SDR"}
AUDIO_CODECS = {"dolby digital plus": "DD+", "e-ac3": "DD+", "eac3": "DD+",
"dolby digital": "DD", "ac3": "DD", "truehd": "TrueHD",
"dts:x": "DTS:X", "dts-x": "DTS:X", "dts-hd ma": "DTS-HD MA",
"dts-hd master audio": "DTS-HD MA", "dts-hd": "DTS-HD", "dts": "DTS",
"aac": "AAC", "flac": "FLAC", "lpcm": "PCM", "pcm": "PCM", "opus": "Opus"}
def _find(text, names):
"""The short name of the longest of `names` found in `text`, or None."""
lowered = text.lower()
hits = [key for key in names if re.search(rf"(?<![\w+]){re.escape(key)}(?![\w+])", lowered)]
return names[max(hits, key=len)] if hits else None
def short_output(output):
""""3840X2160P @ 23.976Hz YUV422 12Bits SDR" -> "2160p 422 12b SDR":
the picture's height, its chroma subsampling and bit depth, and its
dynamic range. Anything not in that shape is passed on as it is."""
if not output:
return None
size = re.search(r"\d+\s*[xX]\s*(\d+)\s*([PpIi])?", output)
if not size:
return output
height = size.group(1) + (size.group(2) or "p").lower()
# YUV422 or YCbCr444 down to the digits, which are what tell them apart;
# RGB has no subsampling to give, so it keeps its name.
chroma = re.search(r"(?:YUV|YCbCr)\s*(4[24][024])|\b(RGB)\b", output, re.I)
chroma = (chroma.group(1) or chroma.group(2).upper()) if chroma else None
bits = re.search(r"(\d+)\s*-?\s*bits?\b", output, re.I)
bits = f"{bits.group(1)}b" if bits else None
return " ".join(filter(None, (height, chroma, bits, _find(output, DYNAMIC_RANGES))))
def short_audio(audio):
""""English Dolby Digital Plus with Dolby Atmos 48.0KHz 768kbps (3/3)"
-> "DD+ Atmos": the codec, and Atmos or DTS:X's objects on top of it. A
codec it does not know keeps the Zidoo's own words, minus the sample
rate, bitrate and track count."""
if not audio or audio.lower().startswith("off"):
return None
codec = _find(audio, AUDIO_CODECS)
if codec is None:
return re.sub(r"\s*(\d[\d.]*\s*k?hz|\d+\s*kbps|\(\d+/\d+\))", "", audio, flags=re.I).strip() or None
atmos = "atmos" in audio.lower()
return f"{codec} Atmos" if atmos else codec
class ZidooError(RuntimeError):
"""The Zidoo did not do what it was asked -- off, unreachable, or with
nothing loaded to do it to."""
class ZidooClient:
# The video player answers getPlayStatus and seekTo, but has no route of
# its own for starting or stopping what it is showing -- the box's remote
# takes that, and its media keys reach the player like a press on the
# handset would. One key per state rather than a single toggle, for the
# same reason a room sends the state it wants (see controller.py).
KEYS = {"play": "Key.MediaPlay", "pause": "Key.MediaPause"}
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 -- or, with no year to give, the video output and audio track. "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")
# Nothing to say under the title -- an episode, or a file outside the
# library -- so say how it is playing: the picture going out to the
# TV, then the audio track.
playing_as = " · ".join(filter(None, (short_output(video.get("output")),
short_audio(video.get("audioInfo")))))
track = {"song": title, "artist": str(year) if year else playing_as or 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 play_state(self, state):
"""Start or stop the loaded video. Like seek(), this is something
someone asked for, so a Zidoo that does not answer is an error rather
than nothing to show. The key press is acknowledged at once but the
player takes a moment to act on it, so the next now_playing() can
still report the state it is on its way out of."""
key = self.KEYS.get(state)
if key is None:
raise ZidooError(f"The Zidoo has no key for '{state}' -- only play and pause")
payload = self._get_json("ZidooControlCenter/RemoteControl/sendkey", key=key)
if not payload or payload.get("status") != 200:
raise ZidooError(f"The Zidoo did not {state} -- is it on and reachable?")
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