feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1
- Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV, pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only) - com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist), calendar alarm at wake time, Doze-safe chained check-ins - Both TVs configured for 06:30 daily with the family playlist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
@@ -44,11 +44,76 @@ export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = {
|
||||
albums: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-TV morning routine, run by the Shield photo app (com.dayline.photos) on each TV:
|
||||
* wake the TV, set the volume, start a YouTube Music playlist, then bring up the calendar.
|
||||
* `id` is the TV's Android ID, reported by the app when it checks in.
|
||||
*/
|
||||
export type TvSetting = {
|
||||
id: string;
|
||||
name: string;
|
||||
wake: { enabled: boolean; time: string; days: number[] };
|
||||
music: { enabled: boolean; playlistId: string };
|
||||
volume: { enabled: boolean; level: number };
|
||||
};
|
||||
|
||||
export const MAX_TVS = 10;
|
||||
export const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
export function validTvId(value: unknown) {
|
||||
return typeof value === "string" && /^[a-f0-9]{8,32}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Accepts a YouTube / YouTube Music playlist link or a bare playlist ID; returns the ID. */
|
||||
export function validPlaylistId(value: unknown) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (/^[A-Za-z0-9_-]{10,64}$/.test(text)) return text;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || !["music.youtube.com", "www.youtube.com", "youtube.com", "m.youtube.com"].includes(url.hostname)) return null;
|
||||
const list = url.searchParams.get("list") ?? "";
|
||||
return /^[A-Za-z0-9_-]{10,64}$/.test(list) ? list : null;
|
||||
}
|
||||
|
||||
export const playlistUrl = (playlistId: string) => `https://music.youtube.com/playlist?list=${playlistId}`;
|
||||
|
||||
function validTime(value: unknown) {
|
||||
return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeTvs(raw: unknown): TvSetting[] {
|
||||
const seen = new Set<string>();
|
||||
return (Array.isArray(raw) ? raw : [])
|
||||
.map((entry) => {
|
||||
const id = validTvId(entry?.id);
|
||||
if (!id || seen.has(id)) return null;
|
||||
seen.add(id);
|
||||
const days = Array.isArray(entry.wake?.days)
|
||||
? [...new Set((entry.wake.days as unknown[]).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort()
|
||||
: ALL_DAYS;
|
||||
const level = Number(entry.volume?.level);
|
||||
return {
|
||||
id,
|
||||
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "",
|
||||
wake: { enabled: entry.wake?.enabled === true, time: validTime(entry.wake?.time) ?? "06:30", days },
|
||||
music: { enabled: entry.music?.enabled === true, playlistId: validPlaylistId(entry.music?.playlistId) ?? "" },
|
||||
volume: { enabled: entry.volume?.enabled === true, level: Number.isFinite(level) ? Math.min(100, Math.max(0, Math.round(level))) : 40 },
|
||||
} satisfies TvSetting;
|
||||
})
|
||||
.filter((entry): entry is TvSetting => entry !== null)
|
||||
.slice(0, MAX_TVS);
|
||||
}
|
||||
|
||||
export type Settings = {
|
||||
schema: 1;
|
||||
updatedAt: string;
|
||||
calendars: CalendarSetting[];
|
||||
photos: PhotoSettings;
|
||||
tvs: TvSetting[];
|
||||
};
|
||||
|
||||
/** Accepts photos.app.goo.gl short links and photos.google.com/share links. */
|
||||
@@ -149,6 +214,7 @@ function normalizeSettings(raw: unknown): Settings {
|
||||
.filter((entry): entry is CalendarSetting => entry !== null)
|
||||
.slice(0, MAX_CALENDARS),
|
||||
photos: normalizePhotos(value.photos),
|
||||
tvs: normalizeTvs(value.tvs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,16 +254,17 @@ async function pruneHistory() {
|
||||
}
|
||||
|
||||
/** Saves new settings (only the parts given), first snapshotting the current ones into history. */
|
||||
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos">>, note: string) {
|
||||
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos" | "tvs">>, note: string) {
|
||||
return serialized(async () => {
|
||||
const current = await readSettings();
|
||||
const calendars = patch.calendars ?? current.calendars;
|
||||
const photos = patch.photos ?? current.photos;
|
||||
const tvs = patch.tvs ?? current.tvs;
|
||||
const stamp = new Date().toISOString();
|
||||
const snapshotId = `${stamp.replace(/[:.]/g, "-")}`;
|
||||
await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note });
|
||||
await pruneHistory();
|
||||
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos });
|
||||
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos, tvs });
|
||||
await writeJsonAtomic(settingsPath(), next);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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<unknown> = Promise.resolve();
|
||||
function serialized<T>(task: () => Promise<T>) {
|
||||
const run = queue.then(task, task);
|
||||
queue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function readTvStatus(): Promise<Record<string, TvStatus>> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record<string, TvStatus>;
|
||||
return raw && typeof raw === "object" ? raw : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTvStatus(next: Record<string, TvStatus>) {
|
||||
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<TvEvent, "at">,
|
||||
knownIds: Set<string> = 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 };
|
||||
@@ -27,7 +27,8 @@ type DraftCalendar = {
|
||||
test?: TestResult | "running";
|
||||
};
|
||||
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
|
||||
type Tab = "calendars" | "photos" | "account";
|
||||
type Tab = "calendars" | "photos" | "tvs" | "account";
|
||||
const TAB_LABELS: Record<Tab, string> = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "Account" };
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
|
||||
const response = await fetch(path, {
|
||||
@@ -372,6 +373,10 @@ type UploadView = { key: string; thumb: string; src: string };
|
||||
type PhotosView = { display: PhotoDisplay; albums: AlbumView[]; uploads: UploadView[] };
|
||||
type AlbumDraft = { key: string; id?: string; name: string; enabled: boolean; shareUrl: string; view?: AlbumView; check?: string };
|
||||
|
||||
function minutesSince(timestamp: number) {
|
||||
return (Date.now() - timestamp) / 60_000;
|
||||
}
|
||||
|
||||
function ago(timestamp: number | null) {
|
||||
if (!timestamp) return "never";
|
||||
const minutes = Math.round((Date.now() - timestamp) / 60_000);
|
||||
@@ -693,6 +698,255 @@ function PhotosTab() {
|
||||
);
|
||||
}
|
||||
|
||||
type TvStatusView = {
|
||||
deviceName: string;
|
||||
model: string;
|
||||
ip: string;
|
||||
appVersion: string;
|
||||
lastSeenAt: number;
|
||||
appliedVersion: string;
|
||||
lastEvent: { at: number; kind: string; ok: boolean; detail: string } | null;
|
||||
};
|
||||
type TvView = {
|
||||
id: string;
|
||||
name: string;
|
||||
wake: { enabled: boolean; time: string; days: number[] };
|
||||
music: { enabled: boolean; playlistId: string };
|
||||
volume: { enabled: boolean; level: number };
|
||||
playlistUrl: string;
|
||||
version: string;
|
||||
status: (TvStatusView & { id: string }) | null;
|
||||
};
|
||||
type TvsView = { checkInMinutes: number; tvs: TvView[]; found: (TvStatusView & { id: string })[] };
|
||||
type TvDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
wake: { enabled: boolean; time: string; days: number[] };
|
||||
music: { enabled: boolean; playlist: string };
|
||||
volume: { enabled: boolean; level: number };
|
||||
};
|
||||
|
||||
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function tvDraft(tv: TvView): TvDraft {
|
||||
return { id: tv.id, name: tv.name, wake: { ...tv.wake }, music: { enabled: tv.music.enabled, playlist: tv.playlistUrl }, volume: { ...tv.volume } };
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm font-semibold">
|
||||
<input type="checkbox" className="h-4 w-4 accent-cyan-300" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) {
|
||||
const status = tv.status;
|
||||
if (!status) return <span className="text-amber-300">Never checked in</span>;
|
||||
const applied = status.appliedVersion === tv.version;
|
||||
const seenMinutes = minutesSince(status.lastSeenAt);
|
||||
return (
|
||||
<span className="space-x-2">
|
||||
{applied ? (
|
||||
<span className="text-emerald-300">✓ Settings are on the TV</span>
|
||||
) : (
|
||||
<span className="text-amber-300">Waiting for the TV to pick up changes (it checks every {checkInMinutes} min)</span>
|
||||
)}
|
||||
<span className={seenMinutes > checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TvsTab() {
|
||||
const [view, setView] = useState<TvsView | null>(null);
|
||||
const [drafts, setDrafts] = useState<TvDraft[]>([]);
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const apply = useCallback((next: TvsView) => {
|
||||
setView(next);
|
||||
setDrafts(next.tvs.map(tvDraft));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await api<TvsView>("/api/admin/tvs");
|
||||
if (result.ok) apply(result.data);
|
||||
})();
|
||||
// Keep check-in status fresh without touching unsaved edits.
|
||||
const timer = window.setInterval(async () => {
|
||||
const result = await api<TvsView>("/api/admin/tvs");
|
||||
if (result.ok) setView(result.data);
|
||||
}, 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [apply]);
|
||||
|
||||
const saved = JSON.stringify(view?.tvs.map(tvDraft) ?? []);
|
||||
const dirty = view !== null && JSON.stringify(drafts) !== saved;
|
||||
|
||||
const update = (id: string, change: (draft: TvDraft) => TvDraft) => setDrafts((current) => current.map((draft) => (draft.id === id ? change(draft) : draft)));
|
||||
|
||||
const add = (found: TvsView["found"][number]) =>
|
||||
setDrafts((current) => [
|
||||
...current,
|
||||
{
|
||||
id: found.id,
|
||||
name: found.deviceName || found.model || "TV",
|
||||
wake: { enabled: true, time: "06:30", days: [0, 1, 2, 3, 4, 5, 6] },
|
||||
music: { enabled: false, playlist: "" },
|
||||
volume: { enabled: false, level: 40 },
|
||||
},
|
||||
]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
const result = await api<TvsView>("/api/admin/tvs", { method: "PUT", body: JSON.stringify({ tvs: drafts }) });
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
apply(result.data);
|
||||
setMessage({ tone: "ok", text: `Saved. Each TV picks up its settings within ${result.data.checkInMinutes} minutes; the status below turns green when it has.` });
|
||||
} else setMessage({ tone: "error", text: result.data.error || "Save failed." });
|
||||
};
|
||||
|
||||
if (!view) return <p className="text-sm text-slate-400">Loading…</p>;
|
||||
const byId = new Map(view.tvs.map((tv) => [tv.id, tv]));
|
||||
const draftIds = new Set(drafts.map((draft) => draft.id));
|
||||
const found = view.found.filter((entry) => !draftIds.has(entry.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-slate-400">
|
||||
Morning routine for each TV, run by the <b>Hobbs Family Photos</b> app: wake the TV, set the volume, start the playlist, and bring up the
|
||||
calendar at the wake time (music starts 30 seconds earlier). The TV turns on through HDMI-CEC.
|
||||
</p>
|
||||
|
||||
{drafts.map((draft) => {
|
||||
const tv = byId.get(draft.id);
|
||||
const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null;
|
||||
return (
|
||||
<section key={draft.id} className="space-y-4 rounded-xl border border-white/10 bg-[#0c121d] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<input className={inputClass} value={draft.name} maxLength={40} placeholder="TV name" onChange={(event) => update(draft.id, (d) => ({ ...d, name: event.target.value }))} />
|
||||
<p className="text-xs text-slate-500">
|
||||
{status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id}
|
||||
</p>
|
||||
<p className="text-xs">{tv ? <TvStatusLine tv={tv} checkInMinutes={view.checkInMinutes} /> : <span className="text-amber-300">New — save to send settings</span>}</p>
|
||||
{status?.lastEvent && (
|
||||
<p className={`text-xs ${status.lastEvent.ok ? "text-slate-400" : "text-rose-300"}`}>
|
||||
Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "}
|
||||
{status.lastEvent.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={dangerButton} onClick={() => setDrafts((current) => current.filter((entry) => entry.id !== draft.id))}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-white/5 pt-3">
|
||||
<Toggle label="Wake up and show the calendar" checked={draft.wake.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} />
|
||||
<div className={`flex flex-wrap items-center gap-2 pl-6 ${draft.wake.enabled ? "" : "opacity-40"}`}>
|
||||
<input
|
||||
type="time"
|
||||
className={inputClass.replace("w-full", "w-36")}
|
||||
value={draft.wake.time}
|
||||
disabled={!draft.wake.enabled}
|
||||
onChange={(event) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, time: event.target.value } }))}
|
||||
/>
|
||||
{DAY_LABELS.map((label, day) => {
|
||||
const on = draft.wake.days.includes(day);
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={!draft.wake.enabled}
|
||||
onClick={() =>
|
||||
update(draft.id, (d) => ({
|
||||
...d,
|
||||
wake: { ...d.wake, days: on ? d.wake.days.filter((entry) => entry !== day) : [...d.wake.days, day].sort() },
|
||||
}))
|
||||
}
|
||||
className={`rounded-md px-2 py-1 text-xs font-semibold ${on ? "bg-cyan-300 text-slate-950" : "bg-white/5 text-slate-400"}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
|
||||
<Toggle label="Play music" checked={draft.music.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} />
|
||||
<div className="pl-6">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="YouTube Music playlist link (Share → Copy link)"
|
||||
value={draft.music.playlist}
|
||||
onChange={(event) => update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
|
||||
<Toggle label="Set the volume" checked={draft.volume.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} />
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="flex-1 accent-cyan-300"
|
||||
value={draft.volume.level}
|
||||
disabled={!draft.volume.enabled}
|
||||
onChange={(event) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm tabular-nums">{draft.volume.level}%</span>
|
||||
</div>
|
||||
<p className="pl-6 text-xs text-slate-500">This is the Shield's own volume; the TV's volume still applies on top.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!drafts.length && <p className="text-sm text-slate-400">No TVs set up yet.</p>}
|
||||
|
||||
{found.length > 0 && (
|
||||
<section className="space-y-2 rounded-xl border border-dashed border-white/15 p-4">
|
||||
<h2 className="text-sm font-bold">TVs found on the network</h2>
|
||||
{found.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 text-sm">
|
||||
<span className="flex-1">
|
||||
{entry.deviceName || entry.model} <span className="text-slate-500">· {entry.ip} · checked in {ago(entry.lastSeenAt)}</span>
|
||||
</span>
|
||||
<button type="button" className={ghostButton} onClick={() => add(entry)}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{!found.length && !drafts.length && (
|
||||
<p className="text-xs text-slate-500">TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
|
||||
{busy ? "Saving…" : "Save and send to TVs"}
|
||||
</button>
|
||||
{dirty && (
|
||||
<button type="button" className={ghostButton} onClick={() => apply(view)}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
@@ -752,7 +1006,7 @@ export default function AdminPage() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
|
||||
void check();
|
||||
const fromHash = window.location.hash.replace("#", "") as Tab;
|
||||
if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash);
|
||||
if (fromHash in TAB_LABELS) setTab(fromHash);
|
||||
}, [check]);
|
||||
|
||||
const selectTab = (next: Tab) => {
|
||||
@@ -777,19 +1031,20 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</header>
|
||||
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
|
||||
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
|
||||
{(Object.keys(TAB_LABELS) as Tab[]).map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => selectTab(name)}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
|
||||
>
|
||||
{name}
|
||||
{TAB_LABELS[name]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tab === "calendars" && <CalendarsTab />}
|
||||
{tab === "photos" && <PhotosTab />}
|
||||
{tab === "tvs" && <TvsTab />}
|
||||
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: Request) {
|
||||
return json({ history: await listHistory() });
|
||||
}
|
||||
|
||||
/** Reverts calendars and photo settings to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */
|
||||
/** Reverts calendars and photo settings to a snapshot (TV settings are left as they are). The current state is itself snapshotted first, so a revert can be undone. */
|
||||
export async function POST(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
|
||||
import { ALL_DAYS, MAX_TVS, playlistUrl, readSettings, saveSettings, type TvSetting, validPlaylistId, validTvId } from "../../../_lib/store";
|
||||
import { CHECK_IN_MINUTES, readTvStatus, tvConfig } from "../../../_lib/tvs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function view(tvs: TvSetting[]) {
|
||||
const status = await readTvStatus();
|
||||
const known = new Set(tvs.map((tv) => tv.id));
|
||||
return {
|
||||
checkInMinutes: CHECK_IN_MINUTES,
|
||||
tvs: tvs.map((tv) => ({
|
||||
...tv,
|
||||
playlistUrl: tv.music.playlistId ? playlistUrl(tv.music.playlistId) : "",
|
||||
version: tvConfig(tv).version,
|
||||
status: status[tv.id] ?? null,
|
||||
})),
|
||||
// TVs running the photo app that have checked in but are not configured yet.
|
||||
found: Object.values(status)
|
||||
.filter((entry) => !known.has(entry.id))
|
||||
.sort((left, right) => right.lastSeenAt - left.lastSeenAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const denied = await requireAdmin(request);
|
||||
if (denied) return denied;
|
||||
return json(await view((await readSettings()).tvs));
|
||||
}
|
||||
|
||||
type IncomingTv = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
wake?: { enabled?: boolean; time?: string; days?: number[] };
|
||||
music?: { enabled?: boolean; playlist?: string };
|
||||
volume?: { enabled?: boolean; level?: number };
|
||||
};
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const denied = await requireAdmin(request, { mutating: true });
|
||||
if (denied) return denied;
|
||||
const body = await readJsonBody<{ tvs?: IncomingTv[] }>(request);
|
||||
if (!body || !Array.isArray(body.tvs)) return json({ error: "Expected a list of TVs." }, 400);
|
||||
if (body.tvs.length > MAX_TVS) return json({ error: `At most ${MAX_TVS} TVs.` }, 400);
|
||||
|
||||
const tvs: TvSetting[] = [];
|
||||
for (const [position, entry] of body.tvs.entries()) {
|
||||
const label = entry.name?.trim() || `TV ${position + 1}`;
|
||||
const id = validTvId(entry.id);
|
||||
if (!id) return json({ error: `${label}: unknown TV id; reload the page.` }, 400);
|
||||
const time = String(entry.wake?.time ?? "");
|
||||
if (entry.wake?.enabled && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return json({ error: `${label}: pick a wake time.` }, 400);
|
||||
const playlistText = String(entry.music?.playlist ?? "").trim();
|
||||
const playlistId = playlistText ? validPlaylistId(playlistText) : "";
|
||||
if (playlistText && !playlistId) return json({ error: `${label}: paste a YouTube Music playlist link (https://music.youtube.com/playlist?list=…).` }, 400);
|
||||
if (entry.music?.enabled && !playlistId) return json({ error: `${label}: music is on but no playlist is set.` }, 400);
|
||||
const days = Array.isArray(entry.wake?.days) ? entry.wake.days : ALL_DAYS;
|
||||
if (entry.wake?.enabled && !days.length) return json({ error: `${label}: pick at least one day.` }, 400);
|
||||
tvs.push({
|
||||
id,
|
||||
name: (entry.name ?? "").trim().slice(0, 40),
|
||||
wake: { enabled: entry.wake?.enabled === true, time: time || "06:30", days },
|
||||
music: { enabled: entry.music?.enabled === true, playlistId: playlistId || "" },
|
||||
volume: { enabled: entry.volume?.enabled === true, level: Number(entry.volume?.level ?? 40) },
|
||||
});
|
||||
}
|
||||
if (new Set(tvs.map((tv) => tv.id)).size !== tvs.length) return json({ error: "The same TV is listed twice." }, 400);
|
||||
|
||||
const saved = await saveSettings({ tvs }, `Saved TV settings (${tvs.length} TV(s))`);
|
||||
console.log(`[admin] TV settings saved (${tvs.length} TV(s))`);
|
||||
return json(await view(saved.tvs));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function GET() {
|
||||
{
|
||||
status: "ok",
|
||||
service: "hobbs-family-organizer",
|
||||
version: "1.3.0",
|
||||
version: "1.4.0",
|
||||
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
|
||||
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
|
||||
adminConfigured,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { clientIp, isAdminNetworkAllowed, json } from "../../../_lib/auth";
|
||||
import { readSettings, validTvId } from "../../../_lib/store";
|
||||
import { recordTvCheckIn, tvConfig } from "../../../_lib/tvs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Called by the Shield photo app every few minutes:
|
||||
* GET /api/tv/config?device=<android id>&name=<device name>&model=<model>&app=<app version>&applied=<config version>
|
||||
* Same network rule as admin: direct LAN only, never through a proxy.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const id = validTvId(params.get("device"));
|
||||
if (!id) return json({ error: "Missing or invalid device id." }, 400);
|
||||
const settings = await readSettings();
|
||||
const setting = settings.tvs.find((tv) => tv.id === id);
|
||||
await recordTvCheckIn(
|
||||
id,
|
||||
{ deviceName: params.get("name"), model: params.get("model"), appVersion: params.get("app"), appliedVersion: params.get("applied"), ip: clientIp(request.headers) },
|
||||
undefined,
|
||||
new Set(settings.tvs.map((tv) => tv.id)),
|
||||
);
|
||||
return json(tvConfig(setting));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { clientIp, isAdminNetworkAllowed, json, readJsonBody } from "../../../_lib/auth";
|
||||
import { readSettings, validTvId } from "../../../_lib/store";
|
||||
import { recordTvCheckIn } from "../../../_lib/tvs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Report = { device?: string; kind?: string; ok?: boolean; detail?: string; applied?: string; app?: string };
|
||||
|
||||
/** The Shield photo app reports the outcome of its morning routine here (shown in admin). */
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
|
||||
const body = await readJsonBody<Report>(request);
|
||||
const id = validTvId(body?.device);
|
||||
if (!body || !id) return json({ error: "Missing or invalid device id." }, 400);
|
||||
const settings = await readSettings();
|
||||
await recordTvCheckIn(
|
||||
id,
|
||||
{ appVersion: body.app, appliedVersion: body.applied, ip: clientIp(request.headers) },
|
||||
{ kind: body.kind || "event", ok: body.ok === true, detail: body.detail || "" },
|
||||
new Set(settings.tvs.map((tv) => tv.id)),
|
||||
);
|
||||
return json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user