diff options
| author | dax <me@dax.ist> | 2026-08-04 05:21:18 +0000 |
|---|---|---|
| committer | dax <me@dax.ist> | 2026-08-04 05:21:18 +0000 |
| commit | 90042156c8538cf02efe77a09d0fe671c50e4ab2 (patch) | |
| tree | 0d3e090f33c2ff2fbe65235a2155251dfa844a1d | |
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.
| -rw-r--r-- | .gitignore | 36 | ||||
| -rw-r--r-- | README.md | 52 | ||||
| -rw-r--r-- | package-lock.json | 157 | ||||
| -rw-r--r-- | package.json | 13 | ||||
| -rw-r--r-- | public/app.css | 150 | ||||
| -rw-r--r-- | public/app.js | 281 | ||||
| -rw-r--r-- | public/index.html | 40 | ||||
| -rw-r--r-- | server.mjs | 224 |
8 files changed, 953 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..346e910 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# pathways + +A photo viewer that organises a PhotoPrism library into paths — shared labels, +keywords, cameras, lenses, and dominant colours — instead of a flat grid or +albums. + +The live instance lives at <https://pathways.dax.ist> and reads the PhotoPrism +database directly (read-only) on the same machine. PhotoPrism's admin UI +stays behind basic auth at <https://prism.dax.ist>; pathways is public. + +## How it works + +- `server.mjs` — a single Node process (`nodejs_22`, no build step). It pools + the MariaDB `photoprism` database, groups the primary files into *paths* + (facets with ≥3 photos) and exposes three endpoints: + - `GET /api/paths` — every photo (id, thumb, title, date) plus the paths + - `GET /api/photo/:id` — one photo plus its connections across labels, + keywords, camera, and lens + - `GET /api/health` — liveness probe +- `public/` — the front end. Progressive reveal on scroll, a lightbox with + connection chips that jump you to the linked path, and a dominant-colour + swatch under every thumbnail. In colour mode the paths themselves are + computed client-side from each photo's dominant hue. + +Thumbnails are served as static files by Caddy from +`/srv/photoprism/storage/cache/thumbnails/{a}/{b}/{c}/{hash}_{size}.jpg` +(`720x720_fit` for the grid, `1920x1200_fit` in the lightbox). + +## Run it + +```bash +npm install +DB_HOST=127.0.0.1 DB_USER=photoprism DB_PASSWORD=… DB_NAME=photoprism \ + PORT=3100 THUMBS_ROOT=/srv/photoprism/storage/cache/thumbnails \ + node server.mjs +``` + +On NixOS the box runs it as a systemd unit (`systemd.services.pathways`), +started after `mysql.service`, as user `dax`. + +## Layout + +``` +server.mjs API + static file serving +public/index.html viewer page +public/app.css styling +public/app.js viewer logic (paths, colours, lightbox) +``` + +The thumbnail path sharding matches PhotoPrism: directory = first three +characters of the file hash. Note `file_hash` comes back from MariaDB as a +binary Buffer, so it is decoded to a string before building URLs. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..502601e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,157 @@ +{ + "name": "pathways", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pathways", + "version": "0.2.0", + "dependencies": { + "mysql2": "^3.10.2" + } + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2530250 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "pathways", + "version": "0.2.0", + "private": true, + "type": "module", + "description": "pathways - a novel photo viewer over the photoprism database", + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "mysql2": "^3.10.2" + } +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..b72761c --- /dev/null +++ b/public/app.css @@ -0,0 +1,150 @@ +:root { + --ink: #111; + --paper: #faf8f5; + --line: #ddd6cc; + --accent: #b3000a; + --mono: "SF Mono", ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; + --serif: Georgia, "Times New Roman", serif; +} +* { box-sizing: border-box; } +html { background: var(--paper); } +body { + margin: 0; + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +.mast { + max-width: 980px; + margin: 0 auto; + padding: 48px 24px 8px; +} +.mast h1 { + font-family: var(--serif); + font-weight: 400; + font-size: 44px; + letter-spacing: -0.02em; + margin: 0 0 4px; +} +.mast h1 .dot { color: var(--accent); } +.tag { margin: 0 0 22px; color: #555; font-size: 13px; } +.modes { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.modes button { + font: inherit; + background: none; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 14px; + color: var(--ink); + cursor: pointer; +} +.modes button.on { background: var(--ink); color: var(--paper); border-color: var(--ink); } +.status { margin: 0; color: #888; font-size: 12px; min-height: 18px; } + +.stream { max-width: 980px; margin: 0 auto; padding: 12px 24px 40px; } + +.thread { margin: 34px 0 0; } +.thread-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 10px; +} +.thread-head h2 { + font-family: var(--serif); + font-weight: 400; + font-size: 22px; + margin: 0; +} +.thread-head .count { color: var(--accent); font-size: 12px; } +.strip { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + scrollbar-width: thin; +} +.strip a { flex: 0 0 auto; display: block; } +.strip img { + display: block; + width: 140px; + height: 140px; + object-fit: cover; + border-radius: 3px; + background: #eee; + transition: transform 120ms ease; +} +.strip a:hover img { transform: scale(1.03); } +.swatch { + display: block; + height: 3px; + background: #ccc; + border-radius: 0 0 3px 3px; + margin-top: 2px; +} + +.lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.82); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} +.lightbox[hidden] { display: none; } +.lightbox-inner { + display: flex; + gap: 24px; + max-width: 1180px; + width: calc(100% - 40px); + background: var(--paper); + border-radius: 4px; + padding: 18px; + position: relative; + align-items: flex-start; +} +.lightbox-inner img { + max-width: min(760px, 72%); + max-height: 82vh; + object-fit: contain; + border-radius: 2px; + background: #000; +} +.lightbox-side { flex: 1; min-width: 220px; padding-right: 30px; } +.lightbox-side h2 { font-family: var(--serif); font-weight: 400; font-size: 24px; margin: 0 0 4px; } +.dim { color: #666; } +.conns { margin-top: 16px; } +.conns h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0 0 8px; } +.conns .chip { + display: inline-block; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 12px; + margin: 0 6px 6px 0; + font-size: 12px; + cursor: pointer; +} +.conns .chip:hover { border-color: var(--accent); color: var(--accent); } +.conns .chip b { color: var(--accent); font-weight: 600; } +.close { + position: absolute; + top: 8px; + right: 12px; + border: none; + background: none; + font-size: 26px; + cursor: pointer; + color: #555; +} +.close:hover { color: var(--ink); } + +.foot { + max-width: 980px; + margin: 0 auto; + padding: 10px 24px 40px; + color: #aaa; + font-size: 11px; +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..406b6ce --- /dev/null +++ b/public/app.js @@ -0,0 +1,281 @@ +"use strict" + +const $ = (sel) => document.querySelector(sel) +const stream = $("#stream") +const status = $("#status") +const modes = $("#modes") + +const PHOTOS = new Map() // id -> {thumb, title, date} +const FACETS = new Map() // type -> facet[] +const COLOURS = new Map() // photo id -> [r,g,b] + +let currentMode = "label" +let revealed = 0 +const PER_BATCH = 4 + +/* ---- tiny observable-style stream ---- */ + +const observable = (subscribe) => { + const listeners = [] + subscribe({ + next: (v) => listeners.slice().forEach((fn) => fn(v)), + error: (e) => listeners.slice().forEach((fn) => fn && fn(e)), + }) + return { + subscribe: (fn) => { + listeners.push(fn) + return () => listeners.splice(listeners.indexOf(fn), 1) + }, + } +} + +const fromEvent = (target, event) => + observable(({ next }) => target.addEventListener(event, next)) + +const debounce = (ms) => (source) => + observable(({ next }) => { + let t + return source.subscribe((v) => { + clearTimeout(t) + t = setTimeout(() => next(v), ms) + }) + }) + +const map = (fn) => (source) => + observable(({ next }) => source.subscribe((v) => next(fn(v)))) + +/* ---- dominant colour from a thumbnail ---- */ + +function colourFrom(img) { + const size = 24 + const canvas = document.createElement("canvas") + canvas.width = size + canvas.height = size + const ctx = canvas.getContext("2d") + try { + ctx.drawImage(img, 0, 0, size, size) + const { data } = ctx.getImageData(0, 0, size, size) + let r = 0, g = 0, b = 0, n = 0 + for (let i = 0; i < data.length; i += 16) { + r += data[i]; g += data[i + 1]; b += data[i + 2]; n++ + } + return [Math.round(r / n), Math.round(g / n), Math.round(b / n)] + } catch { + return [128, 128, 128] + } +} + +function rgbToHue([r, g, b]) { + r /= 255; g /= 255; b /= 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + const d = max - min + if (d === 0) return -1 + let h + if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h = Math.round(h * 60) + return h < 0 ? h + 360 : h +} + +const HUE_NAMES = [ + "red", "orange", "yellow", "yellow", "green", "green", + "green", "cyan", "blue", "blue", "violet", "magenta", "red", +] + +async function colourFor(id, src) { + if (COLOURS.has(id)) return COLOURS.get(id) + return new Promise((resolve) => { + const img = new Image() + img.crossOrigin = "anonymous" + img.onload = () => { const c = colourFrom(img); COLOURS.set(id, c); resolve(c) } + img.onerror = () => { const c = [128, 128, 128]; COLOURS.set(id, c); resolve(c) } + img.src = src + }) +} + +/* ---- rendering ---- */ + +function thumbEl(photo, { full = false } = {}) { + const a = document.createElement("a") + a.href = `#photo-${photo.id}` + a.dataset.id = photo.id + const img = document.createElement("img") + img.loading = "lazy" + img.alt = photo.title || "photo" + img.src = photo.thumb + a.appendChild(img) + const sw = document.createElement("span") + sw.className = "swatch" + a.appendChild(sw) + colourFor(photo.id, photo.thumb).then(([r, g, b]) => { + sw.style.background = `rgb(${r},${g},${b})` + }) + a.addEventListener("click", (e) => { e.preventDefault(); openLightbox(photo.id) }) + return a +} + +function threadEl(facet) { + const sec = document.createElement("section") + sec.className = "thread" + sec.id = `thread-${facet.type}-${facet.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` + const head = document.createElement("div") + head.className = "thread-head" + const h = document.createElement("h2") + h.textContent = facet.name + const c = document.createElement("span") + c.className = "count" + c.textContent = `${facet.count}` + head.append(h, c) + const strip = document.createElement("div") + strip.className = "strip" + for (const id of facet.ids) { + const p = PHOTOS.get(id) + if (p) strip.appendChild(thumbEl(p)) + } + sec.append(head, strip) + return sec +} + +function renderChunk() { + const facets = FACETS.get(currentMode) || [] + const next = facets.slice(revealed, revealed + PER_BATCH) + const frag = document.createDocumentFragment() + for (const f of next) frag.appendChild(threadEl(f)) + stream.appendChild(frag) + revealed += next.length + if (revealed >= facets.length) { + status.textContent = `${facets.length} paths shown` + sentinel.unobserve() + } else { + status.textContent = `${revealed} of ${facets.length} paths` + } +} + +const sentinel = document.createElement("div") +sentinel.id = "sentinel" + +const reveal$ = map(() => renderChunk())(debounce(60)(fromEvent(window, "scroll"))) +reveal$.subscribe(() => {}) +new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) renderChunk() +}).observe(sentinel) + +/* ---- colour paths ---- */ + +const SWATCH_NAMES = [ + "red", "orange", "gold", "yellow", "lime", "green", + "teal", "cyan", "azure", "blue", "violet", "magenta", +] + +function colourPaths(ids) { + const buckets = new Map() + for (const id of ids) { + const c = COLOURS.get(id) + if (!c) continue + const hue = rgbToHue(c) + const bucket = hue < 0 ? 0 : Math.round(hue / 30) % 12 + if (!buckets.has(bucket)) buckets.set(bucket, []) + buckets.get(bucket).push(id) + } + const out = [] + for (const [bucket, bucketIds] of buckets) { + if (bucketIds.length < 3) continue + out.push({ type: "colour", label: "Colours", name: SWATCH_NAMES[bucket], count: bucketIds.length, ids: bucketIds }) + } + return out.sort((a, b) => b.count - a.count) +} + +/* ---- lightbox ---- */ + +async function openLightbox(id) { + const box = $("#lightbox") + const resp = await fetch(`/api/photo/${id}`) + if (!resp.ok) return + const data = await resp.json() + $("#lightbox-img").src = data.photo.full + $("#lightbox-title").textContent = data.photo.title || "untitled" + $("#lightbox-date").textContent = data.photo.date + const conns = $("#lightbox-conns") + conns.innerHTML = "" + const h = document.createElement("h3") + h.textContent = "connections" + conns.appendChild(h) + for (const c of data.connections) { + const chip = document.createElement("span") + chip.className = "chip" + chip.textContent = `${c.name} · ` + const b = document.createElement("b") + b.textContent = `${c.n}` + chip.appendChild(b) + chip.addEventListener("click", () => { closeLightbox(); jumpTo(c.type, c.name) }) + conns.appendChild(chip) + } + box.hidden = false +} + +function closeLightbox() { + $("#lightbox").hidden = true +} + +function jumpTo(type, name) { + setMode(type) + const t = FACETS.get(type).find((f) => f.name === name) + if (!t) return + const el = document.getElementById(`thread-${type}-${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`) + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }) + el.firstElementChild.animate([{ opacity: 0.4 }, { opacity: 1 }], { duration: 700 }) + } +} + +/* ---- mode switching ---- */ + +function setMode(mode) { + if (mode === currentMode && revealed > 0) return + currentMode = mode + revealed = 0 + stream.innerHTML = "" + stream.appendChild(sentinel) + for (const b of modes.children) b.classList.toggle("on", b.dataset.mode === mode) + renderChunk() +} + +function buildColourMode() { + if (FACETS.has("colour")) return + const ids = Array.from(PHOTOS.keys()) + Promise.all(ids.map((id) => colourFor(id, PHOTOS.get(id).thumb))).then(() => { + FACETS.set("colour", colourPaths(ids)) + if (currentMode === "colour") renderChunk() + }) +} + +modes.addEventListener("click", (e) => { + const b = e.target.closest("button") + if (!b) return + const mode = b.dataset.mode + setMode(mode) + if (mode === "colour") buildColourMode() +}) + +$("#lightbox-close").addEventListener("click", closeLightbox) +$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) closeLightbox() }) +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox() }) + +/* ---- boot ---- */ + +async function boot() { + status.textContent = "finding paths…" + const resp = await fetch("/api/paths") + if (!resp.ok) { status.textContent = "could not reach the library"; return } + const data = await resp.json() + for (const [id, p] of Object.entries(data.photos)) PHOTOS.set(Number(id), p) + for (const f of data.facets) { + if (!FACETS.has(f.type)) FACETS.set(f.type, []) + FACETS.get(f.type).push(f) + } + stream.appendChild(sentinel) + renderChunk() +} + +boot() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e59101c --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>pathways — photo viewer</title> + <link rel="stylesheet" href="/app.css"> +</head> +<body> + <header class="mast"> + <h1>pathways<span class="dot">.</span></h1> + <p class="tag">a way through the library, along the things photos share.</p> + <nav id="modes" class="modes" aria-label="path type"> + <button data-mode="label" class="on">labels</button> + <button data-mode="keyword">keywords</button> + <button data-mode="camera">cameras</button> + <button data-mode="lens">lenses</button> + <button data-mode="colour">colours</button> + </nav> + <p id="status" class="status"></p> + </header> + + <main id="stream" class="stream"></main> + + <div id="lightbox" class="lightbox" hidden> + <div class="lightbox-inner"> + <img id="lightbox-img" alt=""> + <div class="lightbox-side"> + <h2 id="lightbox-title"></h2> + <p id="lightbox-date" class="dim"></p> + <div id="lightbox-conns" class="conns"></div> + </div> + <button id="lightbox-close" class="close" aria-label="close">×</button> + </div> + </div> + + <footer class="foot">pathways · built over the photoprism database · no tracking</footer> + <script src="/app.js" defer></script> +</body> +</html> 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() |
