// Returns top cameras by photo count (stale-while-revalidate, 5min TTL). // GET /xrpc/social.grain.unspecced.getCameras // // Normalizes raw EXIF make/model strings before returning them so every // client gets consistent, human-readable names. Rows that collide after // normalization are merged (e.g. "RICOH IMAGING COMPANY, LTD. GR III" and // a hypothetical "Ricoh GR III" fold into one entry with summed counts). // // FOLLOW-UPS: // - Results are capped at top 30. The `/cameras` index page uses this // endpoint — if more is ever needed, add an optional `limit` param. // - ~50 records in prod have make="CAMERA" model="CAMERA", likely test // data; they normalize to a single "Camera" row. Harmless, but it's // the one row you might want to filter out if the sidebar ever feels // cluttered. import { defineQuery } from "$hatk"; import { cleanCameraName } from "../helpers/cameraName.ts"; /** Thumbnails carried per camera, enough for the index tile's 2x2 mosaic. */ const THUMBS_PER_CAMERA = 4; type Camera = { camera: string; photoCount: number; thumbs: string[] }; let cache: { data: Camera[]; expires: number } | null = null; const TTL = 5 * 60 * 1000; async function refresh(ctx: any) { const { db } = ctx; const rows = (await db.query(` SELECT make || ' ' || model AS camera, CAST(COUNT(*) AS INTEGER) AS photo_count FROM "social.grain.photo.exif" WHERE make IS NOT NULL AND model IS NOT NULL GROUP BY make, model ORDER BY photo_count DESC, camera ASC `)) as { camera: string; photo_count: number }[]; // Merge rows that collide after normalization. const merged = new Map(); for (const r of rows) { const clean = cleanCameraName(r.camera); if (!clean) continue; merged.set(clean, (merged.get(clean) ?? 0) + r.photo_count); } const ranked = [...merged.entries()] .map(([camera, photoCount]) => ({ camera, photoCount })) .sort((a, b) => b.photoCount - a.photoCount || a.camera.localeCompare(b.camera)) .slice(0, 30); const thumbs = await thumbsByCamera(ctx, new Set(ranked.map((c) => c.camera))); const data: Camera[] = ranked.map((c) => ({ ...c, thumbs: thumbs.get(c.camera) ?? [] })); cache = { data, expires: Date.now() + TTL }; return data; } /** * Newest photos per camera, for the index tiles. * * Normalization happens in JS, after the query — two raw make/model pairs can * fold into one camera — so the window function partitions on the raw pair and * takes a few extra rows, and the merge below trims each camera back down. */ async function thumbsByCamera(ctx: any, wanted: Set): Promise> { const { db, blobUrl, getRecords } = ctx; const perPartition = THUMBS_PER_CAMERA * 2; const rows = (await db.query( `SELECT camera, photo_uri, created_at FROM ( SELECT e.make || ' ' || e.model AS camera, p.uri AS photo_uri, p.created_at AS created_at, ROW_NUMBER() OVER ( PARTITION BY e.make, e.model ORDER BY p.created_at DESC ) AS rn FROM "social.grain.photo.exif" e JOIN "social.grain.photo" p ON p.uri = e.photo WHERE e.make IS NOT NULL AND e.model IS NOT NULL ) ranked WHERE rn <= $1`, [perPartition], )) as { camera: string; photo_uri: string; created_at: string }[]; // Sorted here rather than in SQL: two raw make/model partitions can merge // into one camera, and "newest first" has to hold across the merge, not // within each partition. The outer query has no ORDER BY of its own. const candidates = new Map(); for (const row of rows) { const clean = cleanCameraName(row.camera); if (!clean || !wanted.has(clean)) continue; const list = candidates.get(clean) ?? []; list.push({ uri: row.photo_uri, createdAt: row.created_at }); candidates.set(clean, list); } const uris = new Map(); for (const [camera, list] of candidates) { list.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)); uris.set( camera, list.slice(0, THUMBS_PER_CAMERA).map((c) => c.uri), ); } const photoUris = [...new Set([...uris.values()].flat())]; if (photoUris.length === 0) return new Map(); const photos = await getRecords("social.grain.photo", photoUris); const out = new Map(); for (const [camera, list] of uris) { const urls = list .map((uri) => { const rec = photos.get(uri); return rec ? blobUrl(rec.did, rec.value.photo, "feed_thumbnail") : null; }) .filter((u: string | null): u is string => !!u); if (urls.length) out.set(camera, urls); } return out; } export default defineQuery("social.grain.unspecced.getCameras", async (ctx) => { const { ok } = ctx; if (cache) { if (Date.now() >= cache.expires) refresh(ctx); return ok({ cameras: cache.data }); } return ok({ cameras: await refresh(ctx) }); });