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)); }