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 = "1280x1024_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, f.file_width, f.file_height 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, p.id` ) const byId = new Map() const photoOrder = [] for (const p of photos) { byId.set(p.id, { 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")}` : "", w: p.file_width || 0, h: p.file_height || 0, }) photoOrder.push(p.id) } const rank = new Map(photoOrder.map((id, i) => [id, i])) const paths = new Map() const photoPaths = new Map() function addRows(rows, type, label) { const grouped = new Map() for (const r of rows) { if (!byId.has(r.pid)) continue if (!grouped.has(r.k)) grouped.set(r.k, { key: `${type}:${r.k}`, type, label, name: r.name, ids: [] }) grouped.get(r.k).ids.push(r.pid) } for (const g of grouped.values()) { g.ids.sort((a, b) => rank.get(a) - rank.get(b)) g.count = g.ids.length paths.set(g.key, g) for (const pid of g.ids) { if (!photoPaths.has(pid)) photoPaths.set(pid, []) photoPaths.get(pid).push(g) } } } const [lab] = await pool.query( `SELECT pl.label_id AS k, l.label_name AS name, pl.photo_id AS pid FROM photos_labels pl JOIN labels l ON l.id = pl.label_id`) addRows(lab, "label", "Labels") const [kw] = await pool.query( `SELECT pk.keyword_id AS k, kw.keyword AS name, pk.photo_id AS pid FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id`) addRows(kw, "keyword", "Keywords") const [cam] = await pool.query( `SELECT p.camera_id AS k, CONCAT(c.camera_make, ' ', c.camera_model) AS name, p.id AS pid FROM photos p JOIN cameras c ON c.id = p.camera_id WHERE p.camera_id <> 1 AND p.camera_id IS NOT NULL`) addRows(cam, "camera", "Cameras") const [len] = await pool.query( `SELECT p.lens_id AS k, l.lens_model AS name, p.id AS pid FROM photos p JOIN lenses l ON l.id = p.lens_id WHERE p.lens_id <> 1 AND p.lens_id IS NOT NULL`) addRows(len, "lens", "Lenses") const facets = [] for (const type of ["label", "keyword", "camera", "lens"]) { const list = Array.from(paths.values()) .filter((g) => g.type === type && g.count >= MIN_PATH) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) for (const g of list) facets.push({ type: g.type, label: g.label, name: g.name, count: g.count, ids: g.ids }) } const library = { key: "library:all", type: "library", label: "Library", name: "library", ids: photoOrder, count: photoOrder.length } const DIRS = ["N", "E", "S", "W"] const exits = new Map() for (const pid of photoOrder) { const own = (photoPaths.get(pid) || []) .filter((p) => p.count >= 2) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) const chosen = own.slice(0, 4) while (chosen.length < 4) chosen.push(library) const used = new Map() const usedNext = new Set() const list = [] for (let i = 0; i < 4; i++) { const p = chosen[i] const step = used.get(p.key) || 0 used.set(p.key, step + 1) const idx = p.ids.indexOf(pid) const cyc = p.ids const start = (idx + 1 + step) % cyc.length const walk = [] for (let k = 0; k < 60 && walk.length < cyc.length; k++) walk.push(cyc[(start + k) % cyc.length]) let nextId = cyc[start] for (const wid of walk) { if (wid !== pid && !usedNext.has(wid)) { nextId = wid; break } } usedNext.add(nextId) const wi = walk.indexOf(nextId) const rot = wi > 0 ? walk.slice(wi).concat(walk.slice(0, wi)) : walk list.push({ dir: DIRS[i], path: { type: p.type, name: p.name, count: p.count }, nextId, walk: rot, }) } exits.set(pid, list) } return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets, photoOrder, byId, exits } } 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 } const type = MIME[extname(path)] || "application/octet-stream" const cache = path.endsWith(".html") ? "no-cache" : "max-age=300" res.writeHead(200, { "Content-Type": type, "Cache-Control": cache }) 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, f.file_width, f.file_height 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")}` : "", w: p.file_width || 0, h: p.file_height || 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, { builtAt: state.builtAt, photos: state.photos, facets: state.facets }) } if (path === "/api/random") { if (!state) await refresh() const id = state.photoOrder[Math.floor(Math.random() * state.photoOrder.length)] return json(res, 200, { photo: state.byId.get(id) }) } const exitMatch = path.match(/^\/api\/exits\/(\d+)$/) if (exitMatch) { if (!state) await refresh() const id = Number(exitMatch[1]) const e = state.exits.get(id) if (!e) return json(res, 404, { error: "not found" }) const photos = {} for (const x of e) for (const wid of x.walk) { const ph = state.byId.get(wid) if (ph) photos[wid] = ph } return json(res, 200, { photo: state.byId.get(id), exits: e.map((x) => ({ dir: x.dir, path: x.path, next: state.byId.get(x.nextId), walk: x.walk })), photos, }) } 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()