diff --git a/system/public/whistlegraph.org/tv.html b/system/public/whistlegraph.org/tv.html index 4d046d8e89..f7f0c7adfb 100644 --- a/system/public/whistlegraph.org/tv.html +++ b/system/public/whistlegraph.org/tv.html @@ -42,7 +42,7 @@ margin-top:2px;text-shadow:1px 1px 0 rgba(0,0,0,.85)} /* the QR scrap: a scannable doorway to the work's record on whistlegraph.org. It never dims — a code you can't scan is furniture. */ - #qr{position:fixed;right:12px;bottom:16px;width:104px;height:104px; + #qr{position:fixed;right:12px;bottom:16px;width:148px;height:148px; border-radius:3px;box-shadow:0 1px 6px rgba(0,0,0,.5);image-rendering:pixelated} /* one thin progress hair at the floor, pink like the archive's margin */ #bar{position:fixed;left:0;bottom:0;height:3px;width:0;background:var(--margin);opacity:.9} diff --git a/toolchain/tv/actv-watchdog.service b/toolchain/tv/actv-watchdog.service index fe56315ef7..c29926edd7 100644 --- a/toolchain/tv/actv-watchdog.service +++ b/toolchain/tv/actv-watchdog.service @@ -6,6 +6,7 @@ Description=Aesthetic Computer TV watchdog check [Service] Type=oneshot -Environment=DISPLAY_NUM=:98 SINK=acsink UNIT=actv AUDIO_CHECK=0 +Environment=DISPLAY_NUM=:98 SINK=acsink UNIT=actv AUDIO_CHECK=0 SIZE=960x540 Environment=PROFILE_DIR=%h/.ac-rig-chrome +Environment="RELOAD_URLS=https://aesthetic.computer/aesthetic.computer/lib/disk.mjs https://aesthetic.computer/aesthetic.computer/disks/chat.mjs https://aesthetic.computer/aesthetic.computer/disks/laklok.mjs" ExecStart=/bin/sh %h/actv/watchdog.sh diff --git a/toolchain/tv/chat-bridge.mjs b/toolchain/tv/chat-bridge.mjs index 45b5b24fd5..fc0b0ae8df 100644 --- a/toolchain/tv/chat-bridge.mjs +++ b/toolchain/tv/chat-bridge.mjs @@ -8,6 +8,13 @@ // sisters render it with the red banner. AC → YouTube needs no bridge — // the stream itself shows the room. // +// The official v3 liveChatMessages API returns bodyless 404s for these +// chats (post-2024 chat-backend migration — Studio- and API-created +// broadcasts alike), so the chat is read the way the watch page reads +// it: InnerTube's get_live_chat with continuations. No OAuth, no quota. +// The v3 API is still used for one thing it does answer: which broadcast +// is live right now. +// // node chat-bridge.mjs // // Env file (0600): @@ -15,11 +22,6 @@ // YT_CLIENT_JSON=/home/jas/actv/yt/client.json // YT_TOKEN_JSON=/home/jas/actv/yt/token.json (AC channel refresh token) // CHAT_WSS=wss://chat-clock.aesthetic.computer -// -// Quota manners: polls every ACTIVE_POLL_MS (20s) only while a broadcast -// is live AND chat has been active in the last five minutes; otherwise -// IDLE_POLL_MS (90s). Between broadcasts it naps a minute. On 403 quota -// errors it backs off ten minutes rather than burning the project dry. import { readFileSync } from "node:fs"; @@ -35,11 +37,10 @@ for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { const SECRET = process.env.CHAT_SERVICE_SECRET; const CHAT_WSS = process.env.CHAT_WSS || "wss://chat-clock.aesthetic.computer"; -const ACTIVE_POLL_MS = Number(process.env.ACTIVE_POLL_MS || 20_000); -const IDLE_POLL_MS = Number(process.env.IDLE_POLL_MS || 90_000); const NO_BROADCAST_MS = 60_000; -const QUOTA_BACKOFF_MS = 600_000; const MAX_TEXT = 128; // chat-manager clamps to shared MAX_CHARS anyway +const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; if (!SECRET) { console.error("CHAT_SERVICE_SECRET missing from env file"); process.exit(1); @@ -47,7 +48,7 @@ if (!SECRET) { const log = (...a) => console.log(`[bridge ${new Date().toISOString().slice(11, 19)}]`, ...a); -// ── YouTube auth (same refresh dance as toolchain/youtube/yt.mjs) ──── +// ── v3 auth, used only to discover the active broadcast id ─────────── const client = (() => { const raw = JSON.parse(readFileSync(process.env.YT_CLIENT_JSON, "utf8")); const c = raw.installed || raw.web || raw; @@ -70,25 +71,78 @@ async function token() { }), }); const json = await res.json(); - if (!res.ok) throw new Error(`token refresh ${res.status}: ${JSON.stringify(json)}`); + if (!res.ok) throw new Error(`token refresh ${res.status}`); accessToken = json.access_token; accessTokenExpiry = Date.now() + (json.expires_in || 3600) * 1000; return accessToken; } -async function yt(path) { +async function activeBroadcastId() { const at = await token(); - const res = await fetch(`https://www.googleapis.com/youtube/v3${path}`, { - headers: { Authorization: `Bearer ${at}` }, + const res = await fetch( + "https://www.googleapis.com/youtube/v3/liveBroadcasts?part=id&broadcastStatus=active&broadcastType=all&maxResults=1", + { headers: { Authorization: `Bearer ${at}` } }, + ); + const raw = await res.text(); + const json = raw ? JSON.parse(raw) : {}; + if (!res.ok) throw new Error(`liveBroadcasts → ${json.error?.errors?.[0]?.reason || res.status}`); + return json.items?.[0]?.id || null; +} + +// ── InnerTube live chat (what the watch page itself speaks) ────────── +// The watch page carries the API key, client version, and the chat's +// initial continuation; get_live_chat then walks continuation → actions +// → next continuation, with a server-suggested timeout between polls. +async function attachChat(videoId) { + const res = await fetch(`https://www.youtube.com/watch?v=${videoId}`, { + headers: { "User-Agent": UA, "Accept-Language": "en" }, }); + const html = await res.text(); + const key = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)?.[1]; + const version = html.match(/"INNERTUBE_CLIENT_VERSION":"([^"]+)"/)?.[1] || "2.20260101.00.00"; + // The live chat's reload continuation lives inside the liveChatRenderer. + const cont = html.match(/"liveChatRenderer":\{"continuations":\[\{"reloadContinuationData":\{"continuation":"([^"]+)"/)?.[1] || + html.match(/"continuation":"(0ofMyA[^"]+)"/)?.[1]; + if (!key || !cont) throw new Error("watch page had no live chat continuation"); + return { key, version, continuation: cont }; +} + +async function pollChat(chat) { + const res = await fetch( + `https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=${chat.key}&prettyPrint=false`, + { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": UA }, + body: JSON.stringify({ + context: { client: { clientName: "WEB", clientVersion: chat.version, hl: "en" } }, + continuation: chat.continuation, + }), + }, + ); + if (!res.ok) throw new Error(`get_live_chat → ${res.status}`); const json = await res.json(); - if (!res.ok) { - const reason = json.error?.errors?.[0]?.reason || res.status; - const err = new Error(`GET ${path.split("?")[0]} → ${reason}`); - err.reason = reason; - throw err; + const lc = json.continuationContents?.liveChatContinuation; + if (!lc) return { messages: [], timeoutMs: null, ended: true }; + + const contObj = lc.continuations?.[0]; + const data = + contObj?.timedContinuationData || + contObj?.invalidationContinuationData || + contObj?.reloadContinuationData; + if (data?.continuation) chat.continuation = data.continuation; + + const messages = []; + for (const action of lc.actions || []) { + const r = action.addChatItemAction?.item?.liveChatTextMessageRenderer; + if (!r) continue; + const name = r.authorName?.simpleText || "viewer"; + const text = (r.message?.runs || []) + .map((run) => run.text || run.emoji?.shortcuts?.[0] || "") + .join("") + .trim(); + if (text) messages.push({ name, text }); } - return json; + return { messages, timeoutMs: data?.timeoutMs ?? 5000, ended: false }; } // ── the AC chat side: one lazy socket, reconnect on demand ─────────── @@ -121,73 +175,51 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const perAuthorLast = new Map(); // displayName → last forwarded epoch ms async function run() { - let liveChatId = null; - let pageToken = ""; - let drained = false; // first page after (re)attach is backlog — don't forward - let lastActivity = 0; + let chat = null; + let drained = false; // first poll returns backlog — don't forward it for (;;) { try { - if (!liveChatId) { - const b = await yt("/liveBroadcasts?part=snippet&broadcastStatus=active&mine=true&maxResults=1"); - liveChatId = b.items?.[0]?.snippet?.liveChatId || null; - if (!liveChatId) { + if (!chat) { + const videoId = await activeBroadcastId(); + if (!videoId) { await sleep(NO_BROADCAST_MS); continue; } - pageToken = ""; + chat = await attachChat(videoId); drained = false; - log("attached to live chat", liveChatId.slice(0, 12) + "…"); + log(`attached to ${videoId} live chat (InnerTube)`); } - const q = new URLSearchParams({ liveChatId, part: "snippet,authorDetails", maxResults: "200" }); - if (pageToken) q.set("pageToken", pageToken); - const page = await yt(`/liveChatMessages?${q}`); - pageToken = page.nextPageToken || pageToken; + const page = await pollChat(chat); + if (page.ended) { + log("chat ended — detaching"); + chat = null; + continue; + } if (!drained) { - drained = true; // history swallowed; everything after this forwards - log(`backlog drained (${page.items?.length || 0} old messages skipped)`); + drained = true; + log(`backlog drained (${page.messages.length} old messages skipped)`); } else { - for (const item of page.items || []) { - if (item.snippet?.type !== "textMessageEvent") continue; - const name = item.authorDetails?.displayName || "viewer"; - let text = (item.snippet.displayMessage || "").trim(); - if (!text) continue; - if (text.length > MAX_TEXT) text = text.slice(0, MAX_TEXT); + for (const { name, text } of page.messages) { + let out = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text; const last = perAuthorLast.get(name) || 0; if (Date.now() - last < 3000) continue; // per-author flood guard perAuthorLast.set(name, Date.now()); - lastActivity = Date.now(); - const ok = await sendToChat(name, text); - log(`${ok ? "→" : "✗ dropped"} [${name}] ${text.slice(0, 60)}`); + const ok = await sendToChat(name, out); + log(`${ok ? "→" : "✗ dropped"} [${name}] ${out.slice(0, 60)}`); } } - // The chat is gone when the broadcast ends; offlineAt also signals it. - if (page.offlineAt) { - log("chat went offline"); - liveChatId = null; - continue; - } - - const hint = Number(page.pollingIntervalMillis || 0); - const busy = Date.now() - lastActivity < 300_000; - await sleep(Math.max(hint, busy ? ACTIVE_POLL_MS : IDLE_POLL_MS)); + await sleep(Math.min(Math.max(page.timeoutMs || 5000, 2000), 30_000)); } catch (err) { - if (err.reason === "quotaExceeded" || err.reason === "rateLimitExceeded") { - log("quota pressure — backing off 10 min"); - await sleep(QUOTA_BACKOFF_MS); - } else if (err.reason === "liveChatEnded" || err.reason === "liveChatNotFound") { - log("live chat ended — reattaching"); - liveChatId = null; - } else { - log("error:", err.message); - await sleep(30_000); - } + log("error:", err.message); + chat = null; + await sleep(30_000); } } } -log(`bridge up → ${CHAT_WSS} (active ${ACTIVE_POLL_MS}ms / idle ${IDLE_POLL_MS}ms)`); +log(`bridge up → ${CHAT_WSS} (InnerTube reader)`); run(); diff --git a/toolchain/tv/rig.sh b/toolchain/tv/rig.sh index 934e240f85..f5261106ba 100755 --- a/toolchain/tv/rig.sh +++ b/toolchain/tv/rig.sh @@ -62,6 +62,12 @@ XVFB_PID=$! sleep 2 log "Xvfb up on $DISPLAY_NUM (${SIZE})" +# No mouse on television — hide the cursor whenever it isn't moving. +if command -v unclutter >/dev/null 2>&1; then + DISPLAY="$DISPLAY_NUM" unclutter -idle 1 -root & + log "unclutter hiding the cursor" +fi + # ── the viewer ─────────────────────────────────────────────────────── # Fresh profile every boot: the TV page remembers sound-on across visits, # and a remembered unmute would turn our one tap into a channel change. @@ -91,13 +97,34 @@ if [ "$UNMUTE_CLICK" = "1" ]; then fi # ── the transmitter ────────────────────────────────────────────────── +# Broadcast legibility: capture at SIZE, transmit at OUT_SIZE. Rendering +# the page small and upscaling makes every glyph and QR proportionally +# bigger in the YouTube frame — Slidecop for the stream. SCALE_FLAGS +# picks the resampler: neighbor keeps pixel UIs crisp, lanczos suits +# video footage. +OUT_SIZE="${OUT_SIZE:-$SIZE}" +SCALE_FLAGS="${SCALE_FLAGS:-lanczos}" +SCALE_ARGS="" +if [ "$OUT_SIZE" != "$SIZE" ]; then + SCALE_ARGS="-vf scale=${OUT_SIZE%x*}:${OUT_SIZE#*x}:flags=$SCALE_FLAGS" + log "upscaling ${SIZE} → ${OUT_SIZE} (${SCALE_FLAGS})" +fi + # Keyframe every 2s (YouTube wants ≤4s), constant-ish bitrate for ingest. +# A/V sync: screen and speaker are two unsynced clocks — wallclock +# timestamps on both inputs + async audio resampling + CFR output keep +# them married instead of drifting apart over a broadcast day. log "ffmpeg → ${RTMP_URL}/(key)" exec ffmpeg -hide_banner -loglevel warning \ + -thread_queue_size 1024 -use_wallclock_as_timestamps 1 \ -f x11grab -framerate "$FPS" -video_size "$SIZE" -i "$DISPLAY_NUM" \ + -thread_queue_size 1024 -use_wallclock_as_timestamps 1 \ -f pulse -i "${SINK}.monitor" \ + $SCALE_ARGS \ + -af "aresample=async=1:first_pts=0" \ + -fps_mode cfr \ -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \ -b:v "$VBITRATE" -maxrate "$VBITRATE" -bufsize 9000k \ -x264-params "keyint=$((FPS * 2)):min-keyint=$((FPS * 2)):scenecut=0" \ - -c:a aac -b:a 160k -ar 44100 -ac 2 \ + -c:a aac -b:a 160k -ar 48000 -ac 2 \ -f flv "${RTMP_URL}/${STREAM_KEY}" diff --git a/toolchain/tv/tvchat.service b/toolchain/tv/tvchat.service index addd1c664c..91d5fcf2ca 100644 --- a/toolchain/tv/tvchat.service +++ b/toolchain/tv/tvchat.service @@ -7,7 +7,7 @@ After=network-online.target [Service] Type=exec -ExecStart=/usr/bin/node %h/actv/chat-bridge.mjs %h/actv/chat-bridge.env +ExecStart=/usr/local/bin/node %h/actv/chat-bridge.mjs %h/actv/chat-bridge.env Restart=always RestartSec=15 diff --git a/toolchain/tv/watchdog.sh b/toolchain/tv/watchdog.sh index b130f75f00..9a653b98bf 100644 --- a/toolchain/tv/watchdog.sh +++ b/toolchain/tv/watchdog.sh @@ -52,6 +52,26 @@ if [ -z "$enter_us" ] || [ "$enter_us" = "0" ] || exit 0 fi +# ── deploy watch: restart when the station's upstream code updates ─── +# The broadcast should always show the latest deploy. RELOAD_URLS lists +# the files whose change means "the page you're airing is stale" — their +# combined Last-Modified/ETag is the fingerprint; a change restarts the +# station (fresh Chrome, fresh code, few seconds of blip). +if [ -n "${RELOAD_URLS:-}" ]; then + marker="" + for u in $RELOAD_URLS; do + marker="$marker|$(curl -sI --max-time 10 "$u" | tr -d '\r' | + awk -F': ' 'tolower($1)=="last-modified"||tolower($1)=="etag"{print $2}' | paste -sd, -)" + done + if [ -n "$(echo "$marker" | tr -d '| ,')" ]; then + prev=$(cat "$WD/deploy_marker" 2>/dev/null || true) + echo "$marker" > "$WD/deploy_marker" + if [ -n "$prev" ] && [ "$marker" != "$prev" ]; then + restart "upstream code updated" + fi + fi +fi + # ── vital 1: the process tree (this station's, not any station's) ──── pgrep -f "user-data-dir=$PROFILE_DIR" >/dev/null || restart "chrome gone" pgrep -f "Xvfb $D" >/dev/null || restart "Xvfb gone" diff --git a/toolchain/tv/wgtv-watchdog.service b/toolchain/tv/wgtv-watchdog.service index 8e986a5f62..2edc932aa8 100644 --- a/toolchain/tv/wgtv-watchdog.service +++ b/toolchain/tv/wgtv-watchdog.service @@ -4,4 +4,8 @@ Description=Whistlegraph TV watchdog check [Service] Type=oneshot +# SIZE must match the station's capture size (wgtv renders 1280x720 and +# upscales in ffmpeg) or the frame grab errors and reads as "frozen". +Environment=SIZE=1280x720 +Environment=RELOAD_URLS=https://tv.whistlegraph.org ExecStart=/bin/sh %h/wgtv/watchdog.sh