From 90042156c8538cf02efe77a09d0fe671c50e4ab2 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 4 Aug 2026 05:21:18 +0000 Subject: pathways: photo viewer over the PhotoPrism library Groups photos into paths by shared label, keyword, camera, lens and dominant colour. Node server.mjs (no build) reads the MariaDB photoprism database read-only; public/ is a zero-dependency front end with progressive reveal, a lightbox with connection chips, and a dominant-colour swatch under every thumbnail. --- server.mjs | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 server.mjs (limited to 'server.mjs') diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..0c1dac6 --- /dev/null +++ b/server.mjs @@ -0,0 +1,224 @@ +import http from "node:http" +import { readFile } from "node:fs/promises" +import { createReadStream, existsSync } from "node:fs" +import { extname, join, normalize } from "node:path" +import mysql from "mysql2/promise" + +const DB = { + host: process.env.DB_HOST || "127.0.0.1", + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER || "photoprism", + password: process.env.DB_PASSWORD || "", + database: process.env.DB_NAME || "photoprism", + connectionLimit: 4, +} + +const PORT = Number(process.env.PORT || 3100) +const THUMBS = process.env.THUMBS_ROOT || "/srv/photoprism/storage/cache/thumbnails" +const PUBLIC = join(process.cwd(), "public") +const MIN_PATH = 3 + +const pool = mysql.createPool(DB) + +const THUMB_SIZE = "720x720_fit" +const BIG_SIZE = "1920x1200_fit" +const thumb = (hash, size = THUMB_SIZE) => { + if (!hash) return null + const s = hash.toString() + return `/thumbs/${s[0]}/${s[1]}/${s[2]}/${s}_${size}.jpg` +} + +let state = null + +async function load() { + const [photos] = await pool.query( + `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day, + f.file_hash + FROM photos p + JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0 + WHERE p.deleted_at IS NULL + ORDER BY p.taken_at_local` + ) + + const byId = new Map() + for (const p of photos) { + byId.set(p.id, { + id: p.id, + thumb: thumb(p.file_hash), + title: p.photo_title || "", + date: p.photo_year + ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}` + : "", + }) + } + + const facetQueries = [ + { + type: "label", + label: "Labels", + q: `SELECT pl.label_id AS k, l.label_name AS name, COUNT(*) AS n + FROM photos_labels pl JOIN labels l ON l.id = pl.label_id + GROUP BY pl.label_id HAVING n >= ? ORDER BY n DESC`, + member: `SELECT label_id AS k, photo_id FROM photos_labels`, + }, + { + type: "keyword", + label: "Keywords", + q: `SELECT pk.keyword_id AS k, kw.keyword AS name, COUNT(*) AS n + FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id + GROUP BY pk.keyword_id HAVING n >= ? ORDER BY n DESC`, + member: `SELECT keyword_id AS k, photo_id FROM photos_keywords`, + }, + { + type: "camera", + label: "Cameras", + q: `SELECT p.camera_id AS k, CONCAT(c.camera_make, ' ', c.camera_model) AS name, COUNT(*) AS n + FROM photos p JOIN cameras c ON c.id = p.camera_id + WHERE p.camera_id <> 1 + GROUP BY p.camera_id HAVING n >= ? ORDER BY n DESC`, + member: `SELECT camera_id AS k, id AS photo_id FROM photos WHERE camera_id <> 1`, + }, + { + type: "lens", + label: "Lenses", + q: `SELECT p.lens_id AS k, l.lens_model AS name, COUNT(*) AS n + FROM photos p JOIN lenses l ON l.id = p.lens_id + WHERE p.lens_id <> 1 + GROUP BY p.lens_id HAVING n >= ? ORDER BY n DESC`, + member: `SELECT lens_id AS k, id AS photo_id FROM photos WHERE lens_id <> 1`, + }, + ] + + const facets = [] + for (const f of facetQueries) { + const [rows] = await pool.query(f.q, [MIN_PATH]) + const [members] = await pool.query(f.member) + const groups = new Map() + for (const m of members) { + if (!groups.has(m.k)) groups.set(m.k, []) + groups.get(m.k).push(m.photo_id) + } + for (const r of rows) { + const ids = (groups.get(r.k) || []).filter((id) => byId.has(id)) + if (ids.length >= MIN_PATH) { + facets.push({ type: f.type, label: f.label, name: r.name, count: ids.length, ids }) + } + } + } + + return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets } +} + +async function refresh() { + try { + state = await load() + } catch (err) { + console.error("pathways: refresh failed:", err.message) + } +} + +const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".ico": "image/x-icon" } + +function serveFile(res, path) { + if (!existsSync(path)) { + res.writeHead(404).end("not found") + return + } + res.writeHead(200, { "Content-Type": MIME[extname(path)] || "application/octet-stream", "Cache-Control": "max-age=300" }) + createReadStream(path).pipe(res) +} + +function json(res, code, data) { + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }) + res.end(JSON.stringify(data)) +} + +async function photoConnections(id) { + const [ph] = await pool.query( + `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day, + f.file_hash + FROM photos p + JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0 + WHERE p.id = ?`, + [id] + ) + if (ph.length === 0) return null + const p = ph[0] + const conns = [] + + const [labels] = await pool.query( + `SELECT l.label_name AS name, COUNT(*) AS n + FROM photos_labels pl JOIN labels l ON l.id = pl.label_id + WHERE pl.photo_id = ? GROUP BY pl.label_id`, + [id] + ) + const [keywords] = await pool.query( + `SELECT kw.keyword AS name, COUNT(*) AS n + FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id + WHERE pk.photo_id = ? GROUP BY pk.keyword_id`, + [id] + ) + const [camera] = await pool.query( + `SELECT CONCAT(c.camera_make, ' ', c.camera_model) AS name, COUNT(*) AS n + FROM photos p JOIN cameras c ON c.id = p.camera_id WHERE p.id = ?`, + [id] + ) + const [lens] = await pool.query( + `SELECT l.lens_model AS name, COUNT(*) AS n + FROM photos p JOIN lenses l ON l.id = p.lens_id WHERE p.id = ?`, + [id] + ) + + for (const r of labels) conns.push({ type: "label", name: r.name, n: r.n }) + for (const r of keywords) conns.push({ type: "keyword", name: r.name, n: r.n }) + for (const r of camera) conns.push({ type: "camera", name: r.name, n: r.n }) + for (const r of lens) conns.push({ type: "lens", name: r.name, n: r.n }) + + return { + photo: { + id: p.id, + thumb: thumb(p.file_hash), + full: thumb(p.file_hash, BIG_SIZE), + title: p.photo_title || "", + date: p.photo_year ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}` : "", + }, + connections: conns, + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) + const path = url.pathname + + try { + if (path === "/api/paths") { + if (!state) await refresh() + return json(res, 200, state) + } + const photoMatch = path.match(/^\/api\/photo\/(\d+)$/) + if (photoMatch) { + const data = await photoConnections(Number(photoMatch[1])) + if (!data) return json(res, 404, { error: "not found" }) + return json(res, 200, data) + } + if (path === "/api/health") return json(res, 200, { ok: true }) + + if (path === "/" || path === "") { + return serveFile(res, join(PUBLIC, "index.html")) + } + const safe = normalize(path).replace(/^(\.\.[/\\])+/, "") + const file = join(PUBLIC, safe) + if (file.startsWith(PUBLIC)) return serveFile(res, file) + + res.writeHead(404).end("not found") + } catch (err) { + console.error("pathways:", err) + json(res, 500, { error: "internal error" }) + } +}) + +server.listen(PORT, "127.0.0.1", () => { + console.log(`pathways listening on 127.0.0.1:${PORT}`) + refresh() +}) +setInterval(refresh, 30 * 60 * 1000).unref() -- cgit v1.3.1