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:
2026-11-04 22:48:50 -06:00
parent 2d16ded0c1
commit 6e94a0c998
22 changed files with 1083 additions and 18 deletions
@@ -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 });
}