aboutsummaryrefslogtreecommitdiff
path: root/server.mjs
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 05:21:18 +0000
committerdax <me@dax.ist>2026-08-04 05:21:18 +0000
commit90042156c8538cf02efe77a09d0fe671c50e4ab2 (patch)
tree0d3e090f33c2ff2fbe65235a2155251dfa844a1d /server.mjs
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.
Diffstat (limited to 'server.mjs')
-rw-r--r--server.mjs224
1 files changed, 224 insertions, 0 deletions
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()