From 9ddc13e352143fd48b38f8ddda1c09cef8fc0340 Mon Sep 17 00:00:00 2001 From: franzz Date: Tue, 20 Oct 2026 16:02:20 +0200 Subject: [PATCH] Add zidoo output info (A/V) --- tests/test_zidoo.py | 26 ++++++++++++++++++- zidoo.py | 61 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tests/test_zidoo.py b/tests/test_zidoo.py index f495e6a..598d454 100644 --- a/tests/test_zidoo.py +++ b/tests/test_zidoo.py @@ -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()) diff --git a/zidoo.py b/zidoo.py index 362e6ee..4bd72a6 100644 --- a/zidoo.py +++ b/zidoo.py @@ -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"(? "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"):