import { createHash } from "node:crypto"; import { readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { playlistUrl, type TvSetting } from "./store"; /* * TV check-ins. Each Shield's photo app polls GET /api/tv/config every few minutes with its * Android ID; the server answers with that TV's morning-routine settings and records what the * TV last reported (so the admin page can show "applied" vs "waiting for the TV"). * Status is runtime data, kept apart from settings.json so it never lands in settings history. */ /** How often the app checks in (the app uses this value; keep in sync with the APK default). */ export const CHECK_IN_MINUTES = 5; /** Music starts this many seconds before the wake time; the calendar comes up at the wake time. */ export const MUSIC_LEAD_SECONDS = 30; const MAX_STATUS_ENTRIES = 20; export type TvEvent = { at: number; kind: string; ok: boolean; detail: string }; export type TvStatus = { id: string; deviceName: string; model: string; ip: string; appVersion: string; lastSeenAt: number; appliedVersion: string; lastEvent: TvEvent | null; }; const statusPath = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "tv-status.json"); let queue: Promise = Promise.resolve(); function serialized(task: () => Promise) { const run = queue.then(task, task); queue = run.catch(() => undefined); return run; } export async function readTvStatus(): Promise> { try { const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record; return raw && typeof raw === "object" ? raw : {}; } catch { return {}; } } async function writeTvStatus(next: Record) { const temporary = `${statusPath()}.${process.pid}.tmp`; await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }); await rename(temporary, statusPath()); } const clean = (value: unknown, max: number) => String(value ?? "") .replace(/[^\x20-\x7e]/g, "") .trim() .slice(0, max); /** Records a check-in (and optionally an event such as the morning routine's result). */ export function recordTvCheckIn( id: string, report: { deviceName?: unknown; model?: unknown; appVersion?: unknown; appliedVersion?: unknown; ip: string }, event?: Omit, knownIds: Set = new Set(), ) { return serialized(async () => { const all = await readTvStatus(); const previous = all[id]; // Unknown TVs are remembered (so admin can add them) but capped, oldest dropped first. if (!previous && Object.keys(all).length >= MAX_STATUS_ENTRIES) { const evictable = Object.values(all) .filter((entry) => !knownIds.has(entry.id)) .sort((left, right) => left.lastSeenAt - right.lastSeenAt); if (!evictable.length) return; delete all[evictable[0].id]; } all[id] = { id, deviceName: clean(report.deviceName, 60) || previous?.deviceName || "", model: clean(report.model, 60) || previous?.model || "", ip: report.ip, appVersion: clean(report.appVersion, 20) || previous?.appVersion || "", lastSeenAt: Date.now(), appliedVersion: clean(report.appliedVersion, 20) || previous?.appliedVersion || "", lastEvent: event ? { ...event, kind: clean(event.kind, 20), detail: clean(event.detail, 200), at: Date.now() } : (previous?.lastEvent ?? null), }; await writeTvStatus(all); }); } export function forgetTvStatus(id: string) { return serialized(async () => { const all = await readTvStatus(); if (!all[id]) return; delete all[id]; await writeTvStatus(all); }); } /** What the TV app receives. Unknown TVs get everything disabled. */ export function tvConfig(setting: TvSetting | undefined) { const body = { registered: Boolean(setting), name: setting?.name ?? "", wake: setting?.wake ?? { enabled: false, time: "06:30", days: [] as number[] }, music: { enabled: Boolean(setting?.music.enabled && setting.music.playlistId), // watch?list= starts playback; playlist?list= would only open the playlist page. url: setting?.music.playlistId ? `https://music.youtube.com/watch?list=${setting.music.playlistId}` : "", }, volume: setting?.volume ?? { enabled: false, level: 40 }, musicLeadSeconds: MUSIC_LEAD_SECONDS, checkInMinutes: CHECK_IN_MINUTES, calendarPackage: "com.dayline.tv", }; return { ...body, version: configVersion(body) }; } export function configVersion(body: unknown) { return createHash("sha1").update(JSON.stringify(body)).digest("hex").slice(0, 12); } export { playlistUrl };