diff --git a/app.py b/app.py index 9b2457e..da20a91 100644 --- a/app.py +++ b/app.py @@ -17,7 +17,7 @@ import argparse import os from functools import wraps -from flask import Flask, jsonify, render_template, request +from flask import Flask, jsonify, render_template, request, url_for from werkzeug.middleware.proxy_fix import ProxyFix import config @@ -144,6 +144,18 @@ def _mark_spotify_accounts(rooms: list): room["spotify_account"] = playing_on.get(_spotify_name(room["key"])) +def _link_zidoo_poster(avr: dict): + """Point the AVR's now-playing cover at our own copy of the Zidoo's + poster: the panel is served over https, and the Zidoo only speaks plain + http, which a phone will not load into an https page.""" + track = avr.get("now_playing") + if not track: + return + poster_id = track.pop("poster_id", None) + if poster_id is not None: + track["image"] = url_for("api_zidoo_poster", poster_id=poster_id) + + # --- The UI ----------------------------------------------------------- @app.route("/") def index(): @@ -177,9 +189,22 @@ def api_state(): # Demo rooms are not on anyone's real Spotify, so they bring their own account. if not data.get("demo"): _mark_spotify_accounts(data["rooms"]) + _link_zidoo_poster(data["avr"]) return jsonify(data) +@app.get("/api/zidoo/poster/") +def api_zidoo_poster(poster_id): + """A film's poster, fetched from the Zidoo on the phone's behalf -- see + _link_zidoo_poster().""" + zidoo = getattr(controller, "zidoo", None) + image = zidoo.poster(poster_id) if zidoo else None + if image is None: + return "", 404 + body, mimetype = image + return app.response_class(body, mimetype=mimetype) + + @app.get("/api/targets") @handle_errors def api_targets(): diff --git a/demo.py b/demo.py index 6bd75cb..c944e9c 100644 --- a/demo.py +++ b/demo.py @@ -73,7 +73,8 @@ class DemoController: "connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(), # Standing in for a Zidoo plugged into this input, so the # AVR card's now-playing block has something to show here too. - "now_playing": {"song": "Big Buck Bunny", "artist": None, "image": None} + "now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None, + "position_ms": 241000, "duration_ms": 596000} if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None, }, "heos_ok": True, diff --git a/static/app.js b/static/app.js index acfe03d..1a75dab 100644 --- a/static/app.js +++ b/static/app.js @@ -3,7 +3,6 @@ network round trip before it looks like it did anything feels broken. */ const STEP = Number(document.documentElement.dataset.step) || 2; -const HOST_LABEL = document.documentElement.dataset.host || 'the AVR'; // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. const BASE = document.documentElement.dataset.base || '/'; const POLL_MS = 5000; @@ -26,7 +25,7 @@ const ICONS = { 'lcr-fronts': SPEAKERS, 'surround-sound-system': SPEAKERS, 'subwoofer': SPEAKER, - mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' }, + mediaplayer: { viewbox: '0 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' }, game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' }, tv: TV, tvaudio: TV, @@ -53,29 +52,31 @@ const ui = { foot: el('[data-role="foot"]'), toast: el('[data-role="toast"]'), refresh: el('[data-role="refresh"]'), - splitAll: el('[data-role="split-all"]'), avrStatus: el('[data-role="avr-status"]'), inputButton: el('[data-role="input-button"]'), inputName: el('[data-role="input-name"]'), inputIcon: el('[data-role="input-icon"]'), sheet: el('[data-role="sheet"]'), options: el('[data-role="options"]'), + source: el('.card.source'), joined: el('[data-role="joined"]'), - joinedRooms: el('[data-role="joined-rooms"]'), avrNowPlaying: el('[data-role="avr-now-playing"]'), avrCover: el('[data-role="avr-cover"]'), avrSong: el('[data-role="avr-song"]'), avrArtist: el('[data-role="avr-artist"]'), + avrProgress: el('[data-role="avr-progress"]'), }; -// Shares its shape with a room's {nowPlaying, cover, song, artist} refs, so -// renderTrack() below works for both -- a device plugged into the AVR (a -// Zidoo, say) is "now playing" the same way a room's own stream is. +// Shares its shape with a room's {nowPlaying, cover, song, artist, +// progress} refs, so renderTrack() below works for both -- a device plugged +// into the AVR (a Zidoo, say) is "now playing" the same way a room's own +// stream is. const avrTrack = { nowPlaying: ui.avrNowPlaying, cover: ui.avrCover, song: ui.avrSong, artist: ui.avrArtist, + progress: ui.avrProgress, }; // Same reasoning as a room's own cover: drop one that will not load rather // than leave a broken-image box. @@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; } const rooms = {}; let inputs = []; let currentInput = null; +let avrNowPlaying = null; /* --- transport -------------------------------------------------------- */ async function api(path, body) { @@ -122,7 +124,7 @@ els('.room').forEach((node) => { prev: el('[data-role="prev"]', node), play: el('[data-role="play"]', node), next: el('[data-role="next"]', node), - spotify: el('.spotify-row', node), // absent with no Spotify account set up + spotify: el('.spotify-row', node), // the resume buttons and the host toggle nowPlaying: el('[data-role="now-playing"]', node), cover: el('[data-role="cover"]', node), song: el('[data-role="song"]', node), @@ -169,7 +171,7 @@ function paintRoom(room) { room.meter.style.setProperty('--level', known ? room.volume : 0); room.node.classList.toggle('offline', !room.available); room.steps.forEach((button) => { button.disabled = !room.available; }); - paintTrack(room); + renderTrack(room.available ? room.track : null, room); const playing = room.playState === 'play'; room.node.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing); @@ -187,27 +189,31 @@ function paintRoom(room) { room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped); room.prev.disabled = !room.available; room.next.disabled = !room.available; - // Resuming stays on offer while grouped: only the speakers have Spotify - // buttons, so hiding them here would leave none at all. - if (room.spotify) { - room.spotify.hidden = !room.available; - els('.spotify', room.spotify).forEach((button) => { - button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount)); - }); - } + // Grouped, the room plays whatever the AVR does, so resuming Spotify on it + // is not on offer. They hide one by one rather than the row hiding with + // them, because the row also holds the host toggle, which stays put and + // merely disables. + els('.spotify', room.spotify).forEach((button) => { + button.hidden = !room.available || room.grouped; + button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount)); + }); room.toggle.disabled = !room.available; room.toggle.classList.toggle('busy', room.busy); room.toggle.setAttribute('aria-pressed', String(room.grouped)); - // Where the card sits already says whether it is grouped, so the button - // says what tapping it does instead. - room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`; + // Out of the group the button names where tapping it sends the room; in it, + // where the card sits already says so, so the button says what it does. + room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema'; + // Grouped, its slot is empty and the AVR's card is the one that moves up. + room.slot.classList.toggle('on-top', playing && !room.grouped); placeRoom(room); } -/* Song, artist and cover, each only when there is one. The cover's src is - only touched when the track changes, so a poll never makes it flicker. */ +/* Song, artist, cover and progress, each only when there is one. The + cover's src is only touched when the track changes, so a poll never makes + it flicker. */ function renderTrack(track, refs) { + renderProgress(track, refs.progress); refs.nowPlaying.hidden = !track; if (!track) return; refs.song.textContent = track.song; @@ -222,44 +228,46 @@ function renderTrack(track, refs) { } } -function paintTrack(room) { - renderTrack(room.available ? room.track : null, room); - paintProgress(room); -} - /* The discreet cursor on the line below the song, sized off the same {position_ms, duration_ms} the poll hands back -- absent for anything HEOS never sends a progress event for (an AVR input, an internet radio stream with no fixed length), in which case the line stays plain. */ -function paintProgress(room) { - const track = room.available ? room.track : null; - room.progress.hidden = !track; +function renderProgress(track, progress) { + progress.hidden = !track; if (!track) return; const { position_ms: position, duration_ms: duration } = track; const known = typeof duration === 'number' && duration > 0 && typeof position === 'number'; const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0; - room.progress.classList.toggle('known', known); - room.progress.style.setProperty('--progress', percent); + progress.classList.toggle('known', known); + progress.style.setProperty('--progress', percent); } /* A merged room moves into the host's card, because that is what merging means: one group, playing one thing. Leaving puts the card back in its own slot, which is why the slots exist. */ function placeRoom(room) { - const target = room.grouped ? ui.joinedRooms : room.slot; + const target = room.grouped ? ui.joined : room.slot; if (room.node.parentElement !== target) target.appendChild(room.node); + // Left empty, a slot would still take a gap of its own and double the space. + room.slot.hidden = room.grouped; } function paintGrouping() { const order = Object.keys(rooms); - const inside = Array.from(ui.joinedRooms.children); + const inside = Array.from(ui.joined.children); // Keep them in the order the cards are declared, not the order they joined. inside .slice() .sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room)) - .forEach((node) => ui.joinedRooms.appendChild(node)); + .forEach((node) => ui.joined.appendChild(node)); ui.joined.hidden = inside.length === 0; - ui.splitAll.hidden = inside.length === 0; + // Grouped rooms share the host's transport, so one of them playing means + // the whole group is -- and the tint goes on the group's card. + const groupPlaying = inside.some((node) => node.classList.contains('playing')); + ui.source.classList.toggle('playing', groupPlaying); + // A film on the Zidoo also moves the AVR up. The Zidoo does not say whether + // it is paused, so a loaded film counts, and it gets no tint. + ui.source.classList.toggle('on-top', groupPlaying || Boolean(avrNowPlaying)); } /* Press and hold to keep moving, accelerating as you hold. */ @@ -438,18 +446,6 @@ function applyJoined(joined) { paintGrouping(); } -ui.splitAll.addEventListener('click', async () => { - try { - applyJoined([]); - const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires - applyJoined(data.joined || []); - } catch (error) { - toast(error.message); - } finally { - refresh(); - } -}); - /* --- Spotify: one button per account, asking the card's own Connect receiver to resume that account, instead of always connecting to it from the Spotify app -------------------------------------------------- */ @@ -543,7 +539,6 @@ function render(state) { room.spotifyAccount = incoming.spotify_account || null; paintRoom(room); }); - paintGrouping(); const avr = state.avr || {}; inputs = avr.inputs || []; @@ -552,7 +547,9 @@ function render(state) { setInputIcon(currentInput ? currentInput.code : null); ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); - renderTrack(avr.now_playing || null, avrTrack); + avrNowPlaying = avr.now_playing || null; + renderTrack(avrNowPlaying, avrTrack); + paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits const problems = state.errors || []; ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : ''); diff --git a/static/panel.css b/static/panel.css index bdabfd5..a7ecbbd 100644 --- a/static/panel.css +++ b/static/panel.css @@ -13,6 +13,7 @@ --live: #3ddc97; --warn: #ff7a6b; --radius: 22px; + --gutter: 16px; /* from the screen's side to a card, and from one card to the next */ } * { box-sizing: border-box; } @@ -51,15 +52,20 @@ svg { width: 22px; height: 22px; fill: currentColor; } .app { max-width: 520px; margin: 0 auto; - padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) - max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right)) + max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left)); display: flex; flex-direction: column; - gap: 14px; + gap: var(--gutter); } +/* A playing card goes to the top. The rest stay in markup order (rooms as + room_keys lists them, then the AVR), because items with the same order + keep their place, so playing cards keep that order among themselves too. + Only the order changes, not the DOM, so a knob held mid-drag stays put. */ +.app > .on-top { order: -1; } /* --- header ---------------------------------------------------------- */ -.top { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; } +.top { order: -2; display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; } .top h1 { margin: 0; line-height: 0; } .top .logo { height: 34px; width: auto; display: block; } @@ -84,8 +90,10 @@ svg { width: 22px; height: 22px; fill: currentColor; } } .card.offline { opacity: .5; } /* A tint, not a repaint -- it should read at a glance without competing - with the song title right above it. */ -.card.room.playing { + with the song title right above it. A group plays one thing, so it is the + host's card that lights up, not the rooms merged into it. */ +.card.room.playing, +.card.source.playing { background: linear-gradient(135deg, color-mix(in srgb, var(--live) 25%, var(--card)) 0%, var(--card) 70%); @@ -118,6 +126,9 @@ svg { width: 22px; height: 22px; fill: currentColor; } object-fit: cover; background: var(--raised); } +/* A film's jacket keeps its own portrait shape rather than being cropped + square like an album's. */ +.cover.poster { width: 50px; height: 75px; border-radius: 9px; } .track { display: flex; flex-direction: column; min-width: 0; } .track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .song { font-size: 20px; font-weight: 550; } @@ -212,23 +223,27 @@ svg { width: 22px; height: 22px; fill: currentColor; } .transport.playing .icon-play { display: none; } .transport.playing .icon-pause { display: block; } -/* --- join / leave the AVR, beside the room's name ---------------------- */ +/* --- join / leave the AVR, between the Spotify buttons ----------------- */ +/* Styled like the .spotify buttons it sits between -- same height, corner, + type and full-strength ink -- but sized to its own label instead of + sharing the row equally. Like them, it says "not the active one" with a + plain background rather than by dimming itself, so the three read as + equal choices. */ .toggle { flex: 0 0 auto; - padding: 7px 14px; border-radius: 999px; + height: 50px; padding: 0 14px; border-radius: 16px; background: var(--raised); - display: flex; align-items: center; justify-content: center; gap: 8px; - font-size: 14px; font-weight: 550; white-space: nowrap; - color: var(--muted); + display: flex; align-items: center; justify-content: center; gap: 9px; + font-size: 15px; font-weight: 550; white-space: nowrap; } -.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; } +.toggle .cinema { flex: 0 0 auto; height: 18px; width: auto; color: #fff; } +.toggle .leave { display: none; flex: 0 0 auto; color: #fff; } .toggle[aria-pressed="true"] { background: var(--accent); color: #fff; } -.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; } .toggle:active { transform: scale(.985); } .toggle:disabled { opacity: .5; } .toggle.busy { opacity: .6; } -/* --- Spotify: one resume button per account ----------------------------- */ +/* --- Spotify: one resume button per account, around the host toggle ----- */ .spotify-row { display: flex; gap: 10px; } .spotify { flex: 1; min-width: 0; @@ -260,33 +275,24 @@ svg { width: 22px; height: 22px; fill: currentColor; } .source-button .chevron { color: var(--muted); } /* --- rooms merged into the host's card --------------------------------- */ -.joined { display: flex; flex-direction: column; gap: 12px; } -.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } -.joined-rooms { display: flex; flex-direction: column; gap: 14px; } +.joined { display: flex; flex-direction: column; gap: 14px; } -/* The card stops being a card in here: one border around the group, not - one around every room in it. */ -.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; } -.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; } -/* Flush against the group's own card, so no radius here -- .playing has - to out-specificity the plain "background: none" above to show at all. */ -.joined .card.room.playing { - background: linear-gradient(135deg, - color-mix(in srgb, var(--live) 25%, transparent) 0%, - transparent 70%); -} +/* In here a room is an outline rather than a card: its border is what says + it is playing with the AVR, and the fill -- a tint too, when it plays -- + belongs to the group's card instead. Same specificity as + .card.room.playing, so being later is what wins. */ +.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: 14px; gap: 12px; } .joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); } .joined .cover { width: 52px; height: 52px; border-radius: 10px; } .joined .song { font-size: 18px; } .joined .step { height: 54px; } -.joined .toggle, -.joined .toggle[aria-pressed="true"] { - height: 36px; font-size: 13px; font-weight: 500; - background: none; border: 1px solid var(--edge); color: var(--muted); -} -.joined .toggle .dot { display: none; } -.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; } -.joined .toggle:active { background: var(--raised); color: var(--ink); } +/* In here the Spotify buttons hide and the toggle says "Detach", so it takes + their place and their look: the whole row, a plain background rather than + the pressed accent, and an exit icon where the host's would only muddle. */ +.joined .toggle { flex: 1; min-width: 0; } +.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; } +.joined .toggle .cinema { display: none; } +.joined .toggle .leave { display: block; } /* --- misc ------------------------------------------------------------- */ .wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; } diff --git a/templates/index.html b/templates/index.html index a0e2d8d..b852346 100644 --- a/templates/index.html +++ b/templates/index.html @@ -19,18 +19,29 @@ -{% macro spotify_row(target, accounts) -%} -{% if accounts %} - + + + + - - - + -

diff --git a/tests/test_zidoo.py b/tests/test_zidoo.py new file mode 100644 index 0000000..fc18e74 --- /dev/null +++ b/tests/test_zidoo.py @@ -0,0 +1,102 @@ +"""The Zidoo client against a pretend Zidoo, answering the way a real one +does (see zidoo.py). + + python3 -m unittest discover -s tests -t . +""" + +import json +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from zidoo import ZidooClient # noqa: E402 + +PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4" +PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16 + + +class FakeZidoo(BaseHTTPRequestHandler): + video = None # getPlayStatus's "video", or None for nothing loaded + library = {} # file path -> getAggregationOfFile's answer + posters = {} # poster id -> image bytes + lookups = [] # paths getAggregationOfFile was asked about + + def do_GET(self): + url = urlparse(self.path) + query = {key: values[0] for key, values in parse_qs(url.query).items()} + if url.path == "/ZidooVideoPlay/getPlayStatus": + if self.video is None: + return self._send(404, b"") + return self._json({"status": 200, "video": self.video}) + if url.path == "/ZidooPoster/v2/getAggregationOfFile": + self.lookups.append(query["path"]) + return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"})) + if url.path == "/ZidooPoster/getFile/getPoster": + image = self.posters.get(int(query["id"])) + return self._send(200, image) if image else self._json({"status": 804, "msg": "Error!!!"}) + self._send(404, b"") + + def _json(self, payload): + self._send(200, json.dumps(payload).encode()) + + def _send(self, code, body): + self.send_response(code) # no Content-Type, like the real thing + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +class ZidooTest(unittest.TestCase): + def setUp(self): + FakeZidoo.video = {"status": 1, "title": "A Private War", "path": PATH, + "currentPosition": 1589745, "duration": 6635092} + FakeZidoo.library = {PATH: {"path": PATH, "type": "movie", "video": {"id": 2248, "parentId": 108}, + "movie": {"id": 108, "name": "A Private War", "year": 2018}}} + FakeZidoo.posters = {108: PNG} + FakeZidoo.lookups = [] + self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo) + threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() + self.zidoo = ZidooClient("127.0.0.1", self.server.server_address[1]) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def test_a_film_carries_its_year_progress_and_poster(self): + self.assertEqual(self.zidoo.now_playing(), { + "song": "A Private War", "artist": "2018", "image": None, "poster_id": 108, + "position_ms": 1589745, "duration_ms": 6635092, + }) + + def test_the_poster_is_looked_up_once_per_file(self): + self.zidoo.now_playing() + self.zidoo.now_playing() + self.assertEqual(FakeZidoo.lookups, [PATH]) + + def test_a_file_outside_the_library_has_no_poster_or_year(self): + FakeZidoo.library = {} + track = self.zidoo.now_playing() + self.assertIsNone(track["poster_id"]) + self.assertIsNone(track["artist"]) + + def test_nothing_loaded_is_nothing_playing(self): + FakeZidoo.video = None + self.assertIsNone(self.zidoo.now_playing()) + + def test_poster_is_sniffed_from_its_bytes(self): + self.assertEqual(self.zidoo.poster(108), (PNG, "image/png")) + + def test_an_id_without_a_poster_is_none_not_its_json_error(self): + self.assertIsNone(self.zidoo.poster(2248)) + + +if __name__ == "__main__": + unittest.main() diff --git a/zidoo.py b/zidoo.py index a904b3c..2214321 100644 --- a/zidoo.py +++ b/zidoo.py @@ -12,7 +12,7 @@ an AVR input, not something the rest of the panel depends on. """ import json -import urllib.error +import urllib.parse import urllib.request @@ -20,20 +20,74 @@ 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.""" - try: - with urllib.request.urlopen( - f"{self.base_url}/ZidooVideoPlay/getPlayStatus", timeout=self.timeout - ) as response: - payload = json.loads(response.read()) - except (urllib.error.URLError, ValueError): + 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 - if payload.get("status") != 200: - return None - title = (payload.get("video") or {}).get("title") + video = payload.get("video") or {} + title = video.get("title") if not title: return None - return {"song": title, "artist": None, "image": 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