94 lines
4.2 KiB
Python
94 lines
4.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 urllib.parse
|
|
import urllib.request
|
|
|
|
|
|
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."""
|
|
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")}
|
|
if video.get("duration"):
|
|
track["position_ms"] = video.get("currentPosition") or 0
|
|
track["duration_ms"] = video["duration"]
|
|
return track
|
|
|
|
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
|