From c2e5c1d9dc77e94e718a39592517666410e38cea Mon Sep 17 00:00:00 2001 From: jeremy Date: Wed, 4 Nov 2026 19:18:02 -0600 Subject: [PATCH] feat(hobbs-organizer): v1.2.0 photo frame (/photo) with Google Photos shared albums - /photo full-screen slideshow (crossfade, blurred backdrop or fill, shuffle, date) - admin Photos tab: add shared-album links (check, sync now, status/stale warnings), slideshow options, browser-resized uploads - 6-hourly album sync scraping the share page (Library API no longer exposes user albums), caches 1920px + 64px copies in /data/photos - /api/health/photos -> 503 when an album has not synced for 48h - history/restore covers photo settings; min admin password length 6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3 --- CLAUDE.md | 4 +- .../hf-pdocker-02/hobbs-organizer/README.md | 5 + .../RELEASE-NOTES.md | 14 + .../VERSION | 2 +- .../app/_lib/auth.ts | 2 +- .../app/_lib/photos.ts | 367 ++++++++++++++++++ .../app/_lib/store.ts | 86 +++- .../app/admin/page.tsx | 354 ++++++++++++++++- .../app/api/admin/calendars/route.ts | 2 +- .../app/api/admin/history/route.ts | 8 +- .../app/api/admin/photos/check/route.ts | 27 ++ .../app/api/admin/photos/route.ts | 83 ++++ .../app/api/admin/photos/sync/route.ts | 20 + .../app/api/admin/photos/upload/route.ts | 44 +++ .../app/api/health/photos/route.ts | 13 + .../app/api/health/route.ts | 2 +- .../app/api/photos/img/[dir]/[file]/route.ts | 17 + .../app/api/photos/route.ts | 7 + .../app/photo/layout.tsx | 10 + .../app/photo/page.tsx | 162 ++++++++ .../compose.yaml | 2 +- .../instrumentation.ts | 5 + .../package-lock.json | 4 +- .../package.json | 2 +- 24 files changed, 1215 insertions(+), 27 deletions(-) create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/check/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/sync/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/photos/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/img/[dir]/[file]/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/layout.tsx create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/page.tsx create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/instrumentation.ts diff --git a/CLAUDE.md b/CLAUDE.md index bb39a43..c298eca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD) ├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget) ├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation ├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS). -├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.1.0` since 2026-11-04 evening; `1.0.0` kept for rollback; Watchtower disabled). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`. +├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.2.0` since 2026-11-04 evening; `1.0.0`/`1.1.0` kept for rollback; Watchtower disabled). **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`. ├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26) ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB @@ -195,7 +195,7 @@ compose/ # Standalone Compose definitions (deployed per-node) - MCPO: :8001 - Trivy: :4954 - CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box. -- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`, `adminConfigured`). Dashboard open on LAN (trusted-LAN mode); `/admin` is password-protected and LAN-only (direct 192.168.86.0/24 only -- anything arriving via a proxy gets 404, so do NOT add a Traefik route expecting admin to work through it). No Traefik route. +- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`, `adminConfigured`). Dashboard open on LAN (trusted-LAN mode); `/photo` = photo frame; `/api/health/photos` (503 = album sync stale >48h, **probe this**); `/admin` is password-protected and LAN-only (direct 192.168.86.0/24 only -- anything arriving via a proxy gets 404, so do NOT add a Traefik route expecting admin to work through it). No Traefik route. **bart (192.168.86.167):** - Farm Agent: :8889 diff --git a/compose/hf-pdocker-02/hobbs-organizer/README.md b/compose/hf-pdocker-02/hobbs-organizer/README.md index c0e4a22..b9c9f14 100644 --- a/compose/hf-pdocker-02/hobbs-organizer/README.md +++ b/compose/hf-pdocker-02/hobbs-organizer/README.md @@ -1,5 +1,10 @@ # Hobbs Family Organizer (Shield TV dashboard) — hf-pdocker-02 +**v1.2.0 (2026-11-04): photo frame at http://192.168.86.100:8340/photo** — Google Photos shared albums +(admin → Photos tab: add share link, Check, Sync now; display interval/fit/shuffle/date) + uploads (resized in the browser). +Cache: `/var/lib/hobbs-organizer/data/photos//` (+ `uploads/`), re-synced every 6h. Stale check: `/api/health/photos` (503 after 48h). +Scraping is unofficial (share-page `ds:1` JSON); if Google changes the page, sync errors show in admin and cached photos keep playing. + **v1.1.0 (2026-11-04): admin screen at http://192.168.86.100:8340/admin** — password (set on first visit, LAN only), calendar links (add/remove/rename/color/hide, masked after save, per-calendar Test, history + Restore). Settings live in `/var/lib/hobbs-organizer/data/` (local disk, owner 10001, mode 700): diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md index dee1a2d..28d0f1c 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md @@ -1,3 +1,17 @@ +# 1.2.0 (2026-11-04) + +- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken. + - It picks up new settings and photos within about 30 seconds and reloads itself every 12 hours. +- Admin **Photos** tab: + - Google Photos shared albums: paste the share link, use **Check**, and **Sync now**. The tab shows sync status, stale warnings and errors. + - Slideshow options. + - Photo uploads. Each upload is resized in the browser to 1920px plus a 64px backdrop thumbnail. +- Albums sync every 6 hours (and 15 seconds after start). + - Each sync caches up to 500 photos per album at 1920px under `/data/photos`, skips videos, and removes photos deleted from the album. +- `/api/health/photos` returns 503 when an enabled album has not synced for 48 hours. +- History and Restore now cover photo settings too. A restore re-syncs any album it brings back. +- The minimum admin password length is now 6. + # 1.1.0 (2026-11-04) - Password-protected, LAN-only `/admin` screen for calendar links (masked, test, history/restore). diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION index 9084fa2..26aaba0 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/auth.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/auth.ts index 6bf4750..f0017e4 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/auth.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/auth.ts @@ -6,7 +6,7 @@ const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, key const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; const SESSION_COOKIE = "hfo_admin"; const SESSION_SECONDS = 30 * 24 * 60 * 60; -export const MIN_PASSWORD_LENGTH = 8; +export const MIN_PASSWORD_LENGTH = 6; // ---------- network policy ---------- diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts new file mode 100644 index 0000000..6725fd8 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts @@ -0,0 +1,367 @@ +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { type PhotoAlbum, readSettings } from "./store"; + +/* + * Google Photos shared albums. Google removed third-party read access to user albums from the + * Library API on 2025-03-31, so this reads the public share page the same way a browser does: + * the album's items are embedded as JSON in an AF_initDataCallback({key: 'ds:1', ...}) block. + * This is unofficial and may break when Google changes the page. Cached photos keep showing + * if it does, and the album reports lastError plus a stale lastSuccessAt. + */ + +export const MAX_PHOTOS_PER_ALBUM = 500; +export const MAX_UPLOADS = 500; +export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; +export const STALE_AFTER_MS = 48 * 60 * 60 * 1000; +const PHOTO_EDGE = 1920; +const THUMB_EDGE = 64; +const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"; +const VIDEO_MARKER = "76647426"; + +export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number }; +export type AlbumIndex = { + title: string; + firstAttemptAt: number; + lastAttemptAt: number; + lastSuccessAt: number | null; + lastError: string; + truncated: boolean; + photos: CachedPhoto[]; +}; + +const photosRoot = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "photos"); +export const UPLOADS_DIR = "uploads"; +const dirFor = (name: string) => path.join(photosRoot(), name); +const indexPath = (name: string) => path.join(dirFor(name), "index.json"); + +export function validPhotoDir(name: string) { + return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name); +} +export function validPhotoFile(name: string) { + return /^[a-f0-9]{20}(\.t)?\.jpg$/.test(name); +} +export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file); + +function emptyIndex(): AlbumIndex { + return { title: "", firstAttemptAt: 0, lastAttemptAt: 0, lastSuccessAt: null, lastError: "", truncated: false, photos: [] }; +} + +export async function readIndex(name: string): Promise { + try { + return { ...emptyIndex(), ...(JSON.parse(await readFile(indexPath(name), "utf8")) as Partial) }; + } catch { + return emptyIndex(); + } +} + +async function writeIndex(name: string, index: AlbumIndex) { + await mkdir(dirFor(name), { recursive: true, mode: 0o700 }); + const temporary = `${indexPath(name)}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify(index), { mode: 0o600 }); + await rename(temporary, indexPath(name)); +} + +async function writeFileAtomic(file: string, data: Uint8Array) { + const temporary = `${file}.${process.pid}.tmp`; + await writeFile(temporary, data, { mode: 0o600 }); + await rename(temporary, file); +} + +export function isJpeg(data: Uint8Array) { + return data.length > 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; +} + +async function readLimited(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length") ?? 0); + if (declared > maxBytes) throw new Error("Response too large"); + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new Error("Response too large"); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +// ---------- scraping ---------- + +type ScrapedItem = { googleId: string; baseUrl: string; width: number; height: number; takenAt: number | null; video: boolean }; +export type ScrapedAlbum = { title: string; items: ScrapedItem[]; hasMorePages: boolean }; + +const ALLOWED_PAGE_HOSTS = new Set(["photos.app.goo.gl", "photos.google.com"]); + +async function fetchAlbumPage(shareUrl: string) { + let current = shareUrl; + for (let hop = 0; hop <= 5; hop += 1) { + const url = new URL(current); + if (url.protocol !== "https:" || !ALLOWED_PAGE_HOSTS.has(url.hostname)) throw new Error(`Album link redirected to an unexpected host (${url.hostname})`); + const response = await fetch(url, { + headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", Accept: "text/html" }, + redirect: "manual", + signal: AbortSignal.timeout(20_000), + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get("location"); + if (!location) throw new Error("Album link redirect had no target"); + current = new URL(location, url).toString(); + continue; + } + if (response.status === 404) throw new Error("Album not found. Was the share link removed?"); + if (!response.ok) throw new Error(`Google Photos returned HTTP ${response.status}`); + return new TextDecoder().decode(await readLimited(response, 20 * 1024 * 1024)); + } + throw new Error("Album link redirected too many times"); +} + +export function parseAlbumPage(html: string): ScrapedAlbum { + const match = /AF_initDataCallback\(\{key: 'ds:1'[\s\S]*?data:([\s\S]*?), sideChannel: \{\}\}\);<\/script>/.exec(html); + if (!match) throw new Error("Could not find the photo list on the album page (Google may have changed the page format)"); + let data: unknown; + try { + data = JSON.parse(match[1]); + } catch { + throw new Error("Could not read the photo list on the album page (Google may have changed the page format)"); + } + if (!Array.isArray(data) || !Array.isArray(data[1])) throw new Error("Unexpected album data format"); + const meta = Array.isArray(data[3]) ? data[3] : []; + const items: ScrapedItem[] = []; + for (const raw of data[1] as unknown[]) { + if (!Array.isArray(raw) || typeof raw[0] !== "string" || !Array.isArray(raw[1])) continue; + const media = raw[1] as unknown[]; + const baseUrl = String(media[0] ?? ""); + let host = ""; + try { + host = new URL(baseUrl).hostname; + } catch { + continue; + } + if (host !== "lh3.googleusercontent.com") continue; + const extras = raw[raw.length - 1]; + items.push({ + googleId: raw[0], + baseUrl, + width: Number(media[1]) || 0, + height: Number(media[2]) || 0, + takenAt: typeof raw[2] === "number" ? raw[2] : null, + video: Boolean(extras && typeof extras === "object" && !Array.isArray(extras) && VIDEO_MARKER in extras), + }); + } + return { + title: typeof meta[1] === "string" ? meta[1] : "", + items, + hasMorePages: typeof data[2] === "string" && data[2].length > 0, + }; +} + +export async function scrapeAlbum(shareUrl: string) { + return parseAlbumPage(await fetchAlbumPage(shareUrl)); +} + +async function downloadImage(baseUrl: string, edge: number, maxBytes: number) { + const url = new URL(`${baseUrl}=w${edge}-h${edge}`); + if (url.hostname !== "lh3.googleusercontent.com") throw new Error("Unexpected image host"); + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, redirect: "error", signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Image download failed (HTTP ${response.status})`); + const data = await readLimited(response, maxBytes); + if (!isJpeg(data)) throw new Error("Image was not a JPEG"); + return data; +} + +const photoKey = (googleId: string) => createHash("sha1").update(googleId).digest("hex").slice(0, 20); + +// ---------- syncing ---------- + +const syncing = new Set(); +export const isSyncing = (albumId: string) => syncing.has(albumId); + +async function mapLimit(values: T[], limit: number, task: (value: T) => Promise) { + let next = 0; + const workers = Array.from({ length: Math.min(limit, values.length) }, async () => { + while (next < values.length) { + const value = values[next]; + next += 1; + await task(value); + } + }); + await Promise.all(workers); +} + +export async function syncAlbum(album: PhotoAlbum) { + if (syncing.has(album.id)) return readIndex(album.id); + syncing.add(album.id); + const index = await readIndex(album.id); + const now = Date.now(); + index.lastAttemptAt = now; + if (!index.firstAttemptAt) index.firstAttemptAt = now; + try { + const scraped = await scrapeAlbum(album.shareUrl); + const wanted = scraped.items.filter((item) => !item.video).slice(0, MAX_PHOTOS_PER_ALBUM); + if (!wanted.length && index.photos.length) throw new Error("Album page listed no photos; keeping the cached ones"); + await mkdir(dirFor(album.id), { recursive: true, mode: 0o700 }); + const existing = new Set((await readdir(dirFor(album.id))).filter(validPhotoFile)); + const photos: CachedPhoto[] = []; + const failures: string[] = []; + await mapLimit(wanted, 4, async (item) => { + const key = photoKey(item.googleId); + try { + if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024)); + if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024)); + photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height }); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + }); + const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`])); + for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined); + + const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position])); + photos.sort((left, right) => (order.get(left.key) ?? 0) - (order.get(right.key) ?? 0)); + index.title = scraped.title; + index.photos = photos; + index.truncated = scraped.hasMorePages || scraped.items.length > MAX_PHOTOS_PER_ALBUM; + if (failures.length && !photos.length) throw new Error(`Every download failed: ${failures[0]}`); + index.lastSuccessAt = Date.now(); + index.lastError = failures.length ? `${failures.length} photo(s) failed to download: ${failures[0]}` : ""; + console.log(`[photos] synced "${scraped.title}" (${album.id.slice(0, 8)}): ${photos.length} photos${failures.length ? `, ${failures.length} failed` : ""}`); + } catch (error) { + index.lastError = error instanceof Error ? error.message : String(error); + console.warn(`[photos] sync failed for ${album.id.slice(0, 8)}: ${index.lastError}`); + } finally { + await writeIndex(album.id, index).catch(() => undefined); + syncing.delete(album.id); + } + return index; +} + +/** Deletes cache folders of albums that are no longer in settings. */ +export async function removeOrphanedAlbumFolders() { + const { photos } = await readSettings(); + await mkdir(photosRoot(), { recursive: true, mode: 0o700 }); + const known = new Set(photos.albums.map((album) => album.id)); + for (const entry of await readdir(photosRoot())) { + if (entry !== UPLOADS_DIR && validPhotoDir(entry) && !known.has(entry) && !syncing.has(entry)) { + await rm(dirFor(entry), { recursive: true, force: true }); + } + } +} + +let syncAllRunning: Promise | null = null; + +/** Syncs every enabled album and deletes cache folders of albums that were removed. */ +export function syncAll() { + if (syncAllRunning) return syncAllRunning; + syncAllRunning = (async () => { + await removeOrphanedAlbumFolders(); + const { photos } = await readSettings(); + for (const album of photos.albums.filter((entry) => entry.enabled)) await syncAlbum(album); + })() + .catch((error) => console.warn(`[photos] sync run failed: ${error instanceof Error ? error.message : String(error)}`)) + .finally(() => { + syncAllRunning = null; + }); + return syncAllRunning; +} + +let schedulerStarted = false; +export function startPhotoScheduler() { + if (schedulerStarted) return; + schedulerStarted = true; + const run = () => void syncAll(); + setTimeout(run, 15_000).unref(); + setInterval(run, SYNC_INTERVAL_MS).unref(); +} + +// ---------- uploads ---------- + +let uploadQueue: Promise = Promise.resolve(); +function serializedUploads(task: () => Promise) { + const run = uploadQueue.then(task, task); + uploadQueue = run.catch(() => undefined); + return run; +} + +export function addUpload(photo: Uint8Array, thumb: Uint8Array, meta: { takenAt: number | null; width: number; height: number }) { + return serializedUploads(async () => { + const index = await readIndex(UPLOADS_DIR); + if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`); + const key = createHash("sha1").update(photo).digest("hex").slice(0, 20); + if (index.photos.some((entry) => entry.key === key)) return { key, duplicate: true }; + await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 }); + await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo); + await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb); + index.photos.push({ key, ...meta }); + index.title = "Uploaded photos"; + index.lastSuccessAt = Date.now(); + await writeIndex(UPLOADS_DIR, index); + return { key, duplicate: false }; + }); +} + +export function removeUpload(key: string) { + return serializedUploads(async () => { + if (!/^[a-f0-9]{20}$/.test(key)) return false; + const index = await readIndex(UPLOADS_DIR); + const before = index.photos.length; + index.photos = index.photos.filter((entry) => entry.key !== key); + if (index.photos.length === before) return false; + await writeIndex(UPLOADS_DIR, index); + for (const file of [`${key}.jpg`, `${key}.t.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined); + return true; + }); +} + +// ---------- what the slideshow shows ---------- + +export type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number }; + +export async function currentSlideshow() { + const settings = await readSettings(); + const { albums, ...display } = settings.photos; + const sources = albums.filter((album) => album.enabled).map((album) => album.id); + if (display.includeUploads) sources.push(UPLOADS_DIR); + const photos: SlidePhoto[] = []; + const fingerprint = createHash("sha1").update(settings.updatedAt); + for (const source of sources) { + const index = await readIndex(source); + fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}`); + for (const photo of index.photos) { + photos.push({ + src: `/api/photos/img/${source}/${photo.key}.jpg`, + thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`, + takenAt: photo.takenAt, + width: photo.width, + height: photo.height, + }); + } + } + return { display, photos, version: fingerprint.digest("hex").slice(0, 16) }; +} + +/** Albums whose last successful sync is older than 48h (or never succeeded within 48h of being added). */ +export async function staleAlbums() { + const { photos } = await readSettings(); + const now = Date.now(); + const stale: { id: string; title: string; lastSuccessAt: number | null; lastError: string }[] = []; + for (const album of photos.albums.filter((entry) => entry.enabled)) { + const index = await readIndex(album.id); + const reference = index.lastSuccessAt ?? (index.firstAttemptAt || now); + if (now - reference > STALE_AFTER_MS) stale.push({ id: album.id, title: album.name || index.title, lastSuccessAt: index.lastSuccessAt, lastError: index.lastError }); + } + return stale; +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts index 936880a..846199c 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts @@ -16,12 +16,86 @@ export type CalendarSetting = { enabled: boolean; }; +export type PhotoAlbum = { + id: string; + /** Display name override; empty means "use the album's own title". */ + name: string; + shareUrl: string; + enabled: boolean; +}; + +export type PhotoDisplay = { + intervalSeconds: number; + shuffle: boolean; + showDate: boolean; + fit: "contain-blur" | "cover"; + includeUploads: boolean; +}; + +export type PhotoSettings = PhotoDisplay & { albums: PhotoAlbum[] }; + +export const MAX_ALBUMS = 10; +export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = { + intervalSeconds: 20, + shuffle: true, + showDate: true, + fit: "contain-blur", + includeUploads: true, + albums: [], +}; + export type Settings = { schema: 1; updatedAt: string; calendars: CalendarSetting[]; + photos: PhotoSettings; }; +/** Accepts photos.app.goo.gl short links and photos.google.com/share links. */ +export function validAlbumUrl(value: unknown) { + let url: URL; + try { + url = new URL(String(value).trim()); + } catch { + return null; + } + if (url.protocol !== "https:") return null; + if (url.hostname === "photos.app.goo.gl" && /^\/[A-Za-z0-9_-]{6,64}$/.test(url.pathname)) return `https://photos.app.goo.gl${url.pathname}`; + if (url.hostname === "photos.google.com" && /^\/share\/[A-Za-z0-9_-]{10,200}$/.test(url.pathname)) return url.toString(); + return null; +} + +export function maskAlbumUrl(url: string) { + const parsed = new URL(url); + const id = parsed.pathname.split("/").pop() || ""; + return `${parsed.hostname}/…${id.slice(-4)}`; +} + +function normalizePhotos(raw: unknown): PhotoSettings { + const value = (raw ?? {}) as Partial; + const interval = Number(value.intervalSeconds); + return { + intervalSeconds: Number.isFinite(interval) ? Math.min(3600, Math.max(5, Math.round(interval))) : DEFAULT_PHOTO_SETTINGS.intervalSeconds, + shuffle: value.shuffle !== false, + showDate: value.showDate !== false, + fit: value.fit === "cover" ? "cover" : "contain-blur", + includeUploads: value.includeUploads !== false, + albums: (Array.isArray(value.albums) ? value.albums : []) + .map((entry) => { + const shareUrl = validAlbumUrl(entry?.shareUrl); + if (!shareUrl) return null; + return { + id: typeof entry.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(entry.id) ? entry.id : randomUUID(), + name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "", + shareUrl, + enabled: entry.enabled !== false, + } satisfies PhotoAlbum; + }) + .filter((entry): entry is PhotoAlbum => entry !== null) + .slice(0, MAX_ALBUMS), + }; +} + export type AuthState = { passwordHash: string; sessionSecret: string; @@ -74,6 +148,7 @@ function normalizeSettings(raw: unknown): Settings { }) .filter((entry): entry is CalendarSetting => entry !== null) .slice(0, MAX_CALENDARS), + photos: normalizePhotos(value.photos), }; } @@ -103,7 +178,7 @@ export async function readSettings(): Promise { } } -export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string }; +export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string }; async function pruneHistory() { const files = (await readdir(historyDir())).filter((name) => name.endsWith(".json")).sort(); @@ -112,15 +187,17 @@ async function pruneHistory() { } } -/** Saves new settings, first snapshotting the current ones into history. */ -export function saveSettings(calendars: CalendarSetting[], note: string) { +/** Saves new settings (only the parts given), first snapshotting the current ones into history. */ +export function saveSettings(patch: Partial>, note: string) { return serialized(async () => { const current = await readSettings(); + const calendars = patch.calendars ?? current.calendars; + const photos = patch.photos ?? current.photos; 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 }); + const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos }); await writeJsonAtomic(settingsPath(), next); return next; }); @@ -136,6 +213,7 @@ export async function listHistory(): Promise { id: name.slice(0, -5), savedAt: raw.updatedAt, calendarCount: Array.isArray(raw.calendars) ? raw.calendars.length : 0, + albumCount: Array.isArray(raw.photos?.albums) ? raw.photos.albums.length : 0, note: typeof raw.note === "string" ? raw.note : "", }); } catch { diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx index df84d7c..bf1ba88 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx @@ -26,7 +26,7 @@ type DraftCalendar = { replacing: boolean; test?: TestResult | "running"; }; -type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string }; +type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string }; type Tab = "calendars" | "photos" | "account"; async function api(path: string, init?: RequestInit & { body?: string }) { @@ -331,7 +331,7 @@ function CalendarsTab() {
-

History

+

History (calendars and photo settings)

{history.length === 0 ? (

No earlier versions yet.

) : ( @@ -339,7 +339,7 @@ function CalendarsTab() { {history.map((entry) => (
  • - {formatWhen(entry.savedAt)} · {entry.calendarCount} calendar(s) + {formatWhen(entry.savedAt)} · {entry.calendarCount} calendar(s), {entry.albumCount ?? 0} album(s) + )} + {status && ( + + )} + + + + ); + })} + +

    + In Google Photos, open the album → Share → Create link, and paste it here. Albums re-sync every 6 hours. Adding photos to the + album in Google Photos is all it takes. Google doesn't offer an official way to read albums, so this reads the share page. If Google + changes that page, syncing stops (the photos you already have keep showing) and a warning appears here. +

    +
  • + +
    +

    Slideshow

    + + + {( + [ + ["shuffle", "Shuffle"], + ["showDate", "Show the date taken"], + ["includeUploads", "Include uploaded photos"], + ] as const + ).map(([field, label]) => ( + + ))} +
    + +
    + + {dirty && ( + + )} +
    + {message &&

    {message.text}

    } + +
    +

    Uploaded photos

    +

    For photos that aren't in a Google album. They're resized on this device before upload.

    + + {view.uploads.length > 0 && ( +
    + {view.uploads.map((photo) => ( +
    + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin cached JPEG */} + + +
    + ))} +
    + )} +
    + + ); +} + function AccountTab({ onSignedOut }: { onSignedOut: () => void }) { const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); @@ -381,8 +719,8 @@ function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {

    Change password

    setCurrent(event.target.value)} /> - setNext(event.target.value)} /> - {message &&

    {message.text}

    } @@ -449,11 +787,7 @@ export default function AdminPage() { ))} {tab === "calendars" && } - {tab === "photos" && ( -

    - Photo albums arrive in the next update. -

    - )} + {tab === "photos" && } {tab === "account" && setState("login")} />} )} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/calendars/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/calendars/route.ts index 28eb60c..2456d2b 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/calendars/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/calendars/route.ts @@ -54,7 +54,7 @@ export async function PUT(request: Request) { } if (new Set(next.map((calendar) => calendar.url)).size !== next.length) return json({ error: "The same calendar is listed twice." }, 400); - const saved = await saveSettings(next, `Saved ${next.length} calendar(s)`); + const saved = await saveSettings({ calendars: next }, `Saved ${next.length} calendar(s)`); console.log(`[admin] calendars saved (${saved.calendars.length})`); return json({ calendars: publicView(saved.calendars), updatedAt: saved.updatedAt }); } diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts index 5f98e04..f72b9d3 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts @@ -1,4 +1,5 @@ import { json, readJsonBody, requireAdmin } from "../../../_lib/auth"; +import { syncAll } from "../../../_lib/photos"; import { listHistory, readHistorySnapshot, saveSettings } from "../../../_lib/store"; export const dynamic = "force-dynamic"; @@ -9,14 +10,15 @@ export async function GET(request: Request) { return json({ history: await listHistory() }); } -/** Reverts calendars to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */ +/** Reverts calendars and photo settings to a snapshot. 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; const body = await readJsonBody<{ id?: string }>(request); const snapshot = body?.id ? await readHistorySnapshot(body.id) : null; if (!snapshot) return json({ error: "That version no longer exists." }, 404); - const saved = await saveSettings(snapshot.calendars, `Reverted to version from ${snapshot.updatedAt}`); - console.log(`[admin] calendars reverted to ${body?.id}`); + const saved = await saveSettings({ calendars: snapshot.calendars, photos: snapshot.photos }, `Reverted to version from ${snapshot.updatedAt}`); + console.log(`[admin] settings reverted to ${body?.id}`); + void syncAll(); // re-fetch any album the restored version brings back return json({ ok: true, updatedAt: saved.updatedAt }); } diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/check/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/check/route.ts new file mode 100644 index 0000000..1d7e7a8 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/check/route.ts @@ -0,0 +1,27 @@ +import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth"; +import { MAX_PHOTOS_PER_ALBUM, scrapeAlbum } from "../../../../_lib/photos"; +import { validAlbumUrl } from "../../../../_lib/store"; + +export const dynamic = "force-dynamic"; + +/** Reads a share link without saving it: album title and how many photos/videos it has. */ +export async function POST(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + const body = await readJsonBody<{ shareUrl?: string }>(request); + const shareUrl = validAlbumUrl(body?.shareUrl); + if (!shareUrl) return json({ ok: false, error: "Not a Google Photos share link." }, 400); + try { + const album = await scrapeAlbum(shareUrl); + const videos = album.items.filter((item) => item.video).length; + return json({ + ok: true, + title: album.title, + photos: album.items.length - videos, + videos, + truncated: album.hasMorePages || album.items.length - videos > MAX_PHOTOS_PER_ALBUM, + }); + } catch (error) { + return json({ ok: false, error: error instanceof Error ? error.message : "Could not read the album." }); + } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/route.ts new file mode 100644 index 0000000..bd6f17d --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/route.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { json, readJsonBody, requireAdmin } from "../../../_lib/auth"; +import { isSyncing, readIndex, removeOrphanedAlbumFolders, STALE_AFTER_MS, syncAlbum, UPLOADS_DIR } from "../../../_lib/photos"; +import { MAX_ALBUMS, maskAlbumUrl, type PhotoAlbum, type PhotoSettings, readSettings, saveSettings, validAlbumUrl } from "../../../_lib/store"; + +export const dynamic = "force-dynamic"; + +async function view(photos: PhotoSettings) { + const { albums, ...display } = photos; + const uploads = await readIndex(UPLOADS_DIR); + return { + display, + albums: await Promise.all( + albums.map(async (album) => { + const index = await readIndex(album.id); + return { + id: album.id, + name: album.name, + enabled: album.enabled, + masked: maskAlbumUrl(album.shareUrl), + title: index.title, + photoCount: index.photos.length, + lastSuccessAt: index.lastSuccessAt, + lastAttemptAt: index.lastAttemptAt, + lastError: index.lastError, + truncated: index.truncated, + stale: album.enabled && Date.now() - (index.lastSuccessAt ?? (index.firstAttemptAt || Date.now())) > STALE_AFTER_MS, + syncing: isSyncing(album.id), + }; + }), + ), + uploads: uploads.photos.map((photo) => ({ key: photo.key, thumb: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.t.jpg`, src: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.jpg` })), + }; +} + +export async function GET(request: Request) { + const denied = await requireAdmin(request); + if (denied) return denied; + return json(await view((await readSettings()).photos)); +} + +type IncomingAlbum = { id?: string; name?: string; enabled?: boolean; shareUrl?: string }; +type Incoming = { display?: Partial>; albums?: IncomingAlbum[] }; + +export async function PUT(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + const body = await readJsonBody(request); + if (!body || !Array.isArray(body.albums)) return json({ error: "Expected albums and display settings." }, 400); + if (body.albums.length > MAX_ALBUMS) return json({ error: `At most ${MAX_ALBUMS} albums.` }, 400); + + const current = (await readSettings()).photos; + const existing = new Map(current.albums.map((album) => [album.id, album])); + const albums: PhotoAlbum[] = []; + const toSync: PhotoAlbum[] = []; + for (const [position, entry] of body.albums.entries()) { + const label = entry.name?.trim() || `Album ${position + 1}`; + const previous = entry.id ? existing.get(entry.id) : undefined; + if (entry.id && !previous) return json({ error: `${label}: unknown album; reload the page.` }, 409); + const supplied = typeof entry.shareUrl === "string" && entry.shareUrl.trim() ? validAlbumUrl(entry.shareUrl) : null; + if (entry.shareUrl?.trim() && !supplied) return json({ error: `${label}: paste a Google Photos share link (https://photos.app.goo.gl/… or https://photos.google.com/share/…).` }, 400); + const shareUrl = supplied ?? previous?.shareUrl; + if (!shareUrl) return json({ error: `${label}: a share link is required.` }, 400); + const album: PhotoAlbum = { + id: previous?.id ?? randomUUID(), + name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "", + shareUrl, + enabled: entry.enabled !== false, + }; + albums.push(album); + if (album.enabled && (!previous || previous.shareUrl !== shareUrl || !previous.enabled)) toSync.push(album); + } + if (new Set(albums.map((album) => album.shareUrl)).size !== albums.length) return json({ error: "The same album is listed twice." }, 400); + + const saved = await saveSettings({ photos: { ...current, ...(body.display ?? {}), albums } }, `Saved photo settings (${albums.length} album(s))`); + console.log(`[admin] photo settings saved (${albums.length} album(s))`); + // Fetch new/changed albums now, and drop cache folders of removed ones, without making the browser wait. + void (async () => { + await removeOrphanedAlbumFolders(); + for (const album of toSync) await syncAlbum(album); + })(); + return json(await view(saved.photos)); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/sync/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/sync/route.ts new file mode 100644 index 0000000..62739b3 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/sync/route.ts @@ -0,0 +1,20 @@ +import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth"; +import { syncAlbum, syncAll } from "../../../../_lib/photos"; +import { readSettings } from "../../../../_lib/store"; + +export const dynamic = "force-dynamic"; + +/** Syncs one album ({ id }) or all of them, and waits for the result. */ +export async function POST(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + const body = await readJsonBody<{ id?: string }>(request); + if (body?.id) { + const album = (await readSettings()).photos.albums.find((entry) => entry.id === body.id); + if (!album) return json({ error: "Unknown album." }, 404); + const index = await syncAlbum(album); + return json({ ok: !index.lastError || index.photos.length > 0, photoCount: index.photos.length, lastError: index.lastError }); + } + await syncAll(); + return json({ ok: true }); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts new file mode 100644 index 0000000..8226d72 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts @@ -0,0 +1,44 @@ +import { json, requireAdmin } from "../../../../_lib/auth"; +import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos"; + +export const dynamic = "force-dynamic"; + +/** + * The admin page resizes photos in the browser (max 1920px JPEG plus a 64px thumbnail) + * before sending them, so the server only validates and stores. + */ +export async function POST(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + let form: FormData; + try { + form = await request.formData(); + } catch { + return json({ error: "Expected a photo upload." }, 400); + } + const photo = form.get("photo"); + const thumb = form.get("thumb"); + if (!(photo instanceof Blob) || !(thumb instanceof Blob)) return json({ error: "Missing photo." }, 400); + if (photo.size > 8 * 1024 * 1024 || thumb.size > 256 * 1024) return json({ error: "Photo is too large." }, 413); + const photoBytes = new Uint8Array(await photo.arrayBuffer()); + const thumbBytes = new Uint8Array(await thumb.arrayBuffer()); + if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415); + const takenAt = Number(form.get("takenAt")); + try { + const result = await addUpload(photoBytes, thumbBytes, { + takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null, + width: Math.max(0, Math.round(Number(form.get("width")) || 0)), + height: Math.max(0, Math.round(Number(form.get("height")) || 0)), + }); + return json({ ok: true, ...result }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : "Upload failed." }, 400); + } +} + +export async function DELETE(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + const key = new URL(request.url).searchParams.get("key") || ""; + return (await removeUpload(key)) ? json({ ok: true }) : json({ error: "Not found." }, 404); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/photos/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/photos/route.ts new file mode 100644 index 0000000..db7c306 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/photos/route.ts @@ -0,0 +1,13 @@ +import { currentSlideshow, staleAlbums } from "../../../_lib/photos"; + +export const dynamic = "force-dynamic"; + +/** 503 when any enabled album has not synced successfully for 48 hours. Probed by Prometheus. */ +export async function GET() { + const stale = await staleAlbums(); + const { photos } = await currentSlideshow(); + return Response.json( + { status: stale.length ? "stale" : "ok", photos: photos.length, staleAlbums: stale.map(({ title, lastSuccessAt, lastError }) => ({ title, lastSuccessAt, lastError })) }, + { status: stale.length ? 503 : 200, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts index 2c49909..efd4ba8 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts @@ -15,7 +15,7 @@ export async function GET() { { status: "ok", service: "hobbs-family-organizer", - version: "1.1.0", + version: "1.2.0", calendarConfigurationValid: calendars.some((calendar) => calendar.enabled), calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length, adminConfigured, diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/img/[dir]/[file]/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/img/[dir]/[file]/route.ts new file mode 100644 index 0000000..a19956f --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/img/[dir]/[file]/route.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; +import { photoFilePath, validPhotoDir, validPhotoFile } from "../../../../../_lib/photos"; + +export const dynamic = "force-dynamic"; + +export async function GET(_request: Request, { params }: { params: Promise<{ dir: string; file: string }> }) { + const { dir, file } = await params; + if (!validPhotoDir(dir) || !validPhotoFile(file)) return new Response("Not found", { status: 404 }); + try { + const data = await readFile(photoFilePath(dir, file)); + return new Response(new Uint8Array(data), { + headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", "Content-Length": String(data.byteLength) }, + }); + } catch { + return new Response("Not found", { status: 404 }); + } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/route.ts new file mode 100644 index 0000000..a708612 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/photos/route.ts @@ -0,0 +1,7 @@ +import { currentSlideshow } from "../../_lib/photos"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json(await currentSlideshow(), { headers: { "Cache-Control": "no-store" } }); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/layout.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/layout.tsx new file mode 100644 index 0000000..caf9d5c --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Hobbs Family Photos", + robots: { index: false, follow: false }, +}; + +export default function PhotoLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return children; +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/page.tsx new file mode 100644 index 0000000..dc40271 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/page.tsx @@ -0,0 +1,162 @@ +/* eslint-disable @next/next/no-img-element -- plain is intended: same-origin cached JPEGs, no optimizer */ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number }; +type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean }; +type Slideshow = { display: Display; photos: SlidePhoto[]; version: string }; +type Layer = { photo: SlidePhoto; id: number }; + +const POLL_MS = 30_000; +const FADE_MS = 1_400; +const FULL_RELOAD_MS = 12 * 60 * 60 * 1000; +const TIME_ZONE = "America/Chicago"; + +function shuffled(values: T[]) { + const copy = [...values]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const other = Math.floor(Math.random() * (index + 1)); + [copy[index], copy[other]] = [copy[other], copy[index]]; + } + return copy; +} + +function formatTaken(takenAt: number | null) { + if (!takenAt) return ""; + return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt)); +} + +function preload(src: string) { + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(); + image.onerror = () => resolve(); + image.src = src; + }); +} + +export default function PhotoFrame() { + const [show, setShow] = useState(null); + const [layers, setLayers] = useState([]); + const [failed, setFailed] = useState(false); + const queue = useRef([]); + const versionRef = useRef(""); + const showRef = useRef(null); + const layerId = useRef(0); + // The newest layer's src, readable from callbacks without depending on layers state. + const topSrc = useRef(""); + + const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => { + let next = shuffle ? shuffled(photos) : [...photos]; + if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]]; + queue.current = next; + }, []); + + const advance = useCallback(async () => { + const current = showRef.current; + if (!current || !current.photos.length) return; + if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current); + const photo = queue.current.shift(); + if (!photo) return; + await preload(photo.src); + layerId.current += 1; + const id = layerId.current; + setLayers((existing) => [...existing.slice(-1), { photo, id }]); + }, [refill]); + + useEffect(() => { + topSrc.current = layers[layers.length - 1]?.photo.src ?? ""; + }, [layers]); + + // Poll settings + photo list; rebuild the queue only when something changed. + useEffect(() => { + let cancelled = false; + const poll = async () => { + try { + const response = await fetch("/api/photos", { cache: "no-store" }); + if (!response.ok) throw new Error(String(response.status)); + const next = (await response.json()) as Slideshow; + if (cancelled) return; + setFailed(false); + if (next.version === versionRef.current) return; + const first = !versionRef.current; + versionRef.current = next.version; + showRef.current = next; + setShow(next); + refill(next.photos, next.display.shuffle, topSrc.current); + if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance(); + } catch { + if (!cancelled) setFailed(true); + } + }; + void poll(); + const timer = window.setInterval(poll, POLL_MS); + const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + window.clearTimeout(reload); + }; + }, [advance, refill]); + + const intervalSeconds = show?.display.intervalSeconds ?? 20; + const photoCount = show?.photos.length ?? 0; + useEffect(() => { + if (photoCount < 2) return; + const timer = window.setInterval(() => void advance(), intervalSeconds * 1000); + return () => window.clearInterval(timer); + }, [advance, intervalSeconds, photoCount]); + + const fit = show?.display.fit ?? "contain-blur"; + const showDate = show?.display.showDate ?? true; + const top = layers[layers.length - 1]; + + return ( +
    + {layers.map((layer) => ( +
    + {fit === "contain-blur" && ( + + )} + +
    + ))} + + {showDate && top?.photo.takenAt ? ( +
    + {formatTaken(top.photo.takenAt)} +
    + ) : null} + + {show && !show.photos.length && ( +
    + No photos yet. Add a Google Photos album in the admin screen. +
    + )} + {failed && !layers.length && ( +
    Can't reach the photo server…
    + )} + + +
    + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml index bea56c5..a72d99b 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml @@ -2,7 +2,7 @@ name: hobbs-family-organizer services: organizer: - image: hobbs-family-organizer:1.1.0 + image: hobbs-family-organizer:1.2.0 build: context: . dockerfile: Dockerfile diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/instrumentation.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/instrumentation.ts new file mode 100644 index 0000000..39f08e2 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/instrumentation.ts @@ -0,0 +1,5 @@ +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + const { startPhotoScheduler } = await import("./app/_lib/photos"); + startPhotoScheduler(); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json index 80726ab..56793ce 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json @@ -1,12 +1,12 @@ { "name": "hobbs-family-organizer", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hobbs-family-organizer", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "ical.js": "^2.2.1", "lucide-react": "^1.31.0", diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json index 0be1cff..8365419 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json @@ -1,6 +1,6 @@ { "name": "hobbs-family-organizer", - "version": "1.1.0", + "version": "1.2.0", "private": true, "engines": { "node": ">=22.13.0"