Add zidoo output info (A/V)
Deploy HEOS panel / deploy (push) Successful in 28s

This commit is contained in:
franzz committed 2026-10-20 16:02:20 +02:00
1 parent 6bee9bd9a3
commit 9ddc13e352
2 files changed
+84 -3

No files matched your search

+25 -1
View File
@@ -16,7 +16,7 @@ from urllib.parse import parse_qs, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, TargetError # noqa: E402
from zidoo import ZidooClient, ZidooError # noqa: E402
from zidoo import ZidooClient, ZidooError, short_audio, short_output # noqa: E402
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
@@ -112,6 +112,30 @@ class ZidooTest(unittest.TestCase):
self.assertIsNone(track["poster_id"])
self.assertIsNone(track["artist"])
def test_without_a_year_it_says_how_it_plays(self):
FakeZidoo.library = {}
FakeZidoo.video["output"] = "3840X2160P @ 23.976Hz YUV422 12Bits SDR"
FakeZidoo.video["audioInfo"] = "English Dolby Digital Plus with Dolby Atmos 48.0KHz 768kbps (3/3)"
self.assertEqual(self.zidoo.now_playing()["artist"], "2160p 422 12b SDR · DD+ Atmos")
def test_the_output_shortens_to_height_chroma_bits_and_dynamic_range(self):
self.assertEqual(short_output("3840X2160P @ 23.976Hz YCbCr444 12Bits Dolby Vision"), "2160p 444 12b Dolby Vision")
self.assertEqual(short_output("3840X2160P @ 60Hz RGB 10Bits HDR10+"), "2160p RGB 10b HDR10+")
self.assertEqual(short_output("1920x1080i @ 50Hz"), "1080i")
def test_the_audio_shortens_to_its_codec(self):
# Longest name first: Dolby Digital Plus is not plain Dolby Digital.
self.assertEqual(short_audio("English Dolby Digital 48.0KHz 640kbps (1/1)"), "DD")
self.assertEqual(short_audio("English TrueHD Atmos 48.0KHz (1/2)"), "TrueHD Atmos")
self.assertEqual(short_audio("French DTS-HD MA 7.1 48.0KHz (2/2)"), "DTS-HD MA")
# A codec it does not know keeps its words, minus the numbers.
self.assertEqual(short_audio("Japanese Vorbis 48.0KHz 192kbps (1/1)"), "Japanese Vorbis")
self.assertIsNone(short_audio("Off (0/2)"))
def test_a_film_keeps_its_year_over_how_it_plays(self):
FakeZidoo.video["output"] = "3840X2160P @ 23.976Hz YUV422 12Bits SDR"
self.assertEqual(self.zidoo.now_playing()["artist"], "2018")
def test_nothing_loaded_is_nothing_playing(self):
FakeZidoo.video = None
self.assertIsNone(self.zidoo.now_playing())
+59 -2
View File
@@ -12,10 +12,62 @@ 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."""
@@ -54,7 +106,7 @@ class ZidooClient:
"""{"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
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
@@ -69,7 +121,12 @@ class ZidooClient:
return None
film = self._film(video.get("path"))
year = film.get("year")
track = {"song": title, "artist": str(year) if year else None, "image": None,
# 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"):