feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate

Replace the legacy mule-image backend with PhotoPrism plus a thin
SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't
expose (file rename), and add a two-phase migrator (metadata via PUT,
heaps → albums) for the existing Postgres library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

97
migrate/README.md Normal file
View File

@@ -0,0 +1,97 @@
# migrate — legacy mule-image → PhotoPrism
Two-phase migration that lifts user metadata + heap memberships out of the
legacy mule-image Postgres into PhotoPrism without touching originals.
```text
┌────────────────────┐ legacy_export.mjs ┌─────────────────────┐
│ mule-image Postgres├───────────────────────►│ snapshot.json │
└────────────────────┘ └──────────┬──────────┘
│ run.mjs
┌─────────────────────────────┐
│ PUT /api/v1/photos/:uid │ (Phase A)
│ POST /api/v1/albums + adds │ (Phase B)
└─────────────────────────────┘
```
## Why two phases
- **Phase A — metadata via PUT.** For each legacy photo, the migrator
hashes the file on disk, resolves the PhotoPrism `UID` via
`q=hash:<sha1>`, and PUTs the merged metadata onto `/api/v1/photos/:uid`.
We tried writing YAML sidecars directly first but PhotoPrism's reindex
rewrites them from its DB state — PUT is the only authoritative path.
- **Phase B — heap memberships.** Once every legacy photo has a stable
UID, the heap → Album conversion creates manual Albums and bulk-adds
the resolved UIDs via `/albums/:uid/photos`.
Both phases are idempotent. Phase A's PUT is deep-merged (top-level
spread plus `Details` shallow-merge), so re-running pulls in any new
fields from the snapshot without clobbering server-side state. Phase B
looks up heaps by title and only creates if absent.
## Files
- `legacy_export.mjs` — reads from legacy mule-image Postgres
(`DATABASE_URL` env), emits `snapshot.json`. Runs on the **homecloud server**
where the legacy backend lives.
- `run.mjs` — reads a snapshot file and walks both phases against the
PhotoPrism API at `PHOTOPRISM_BASE_URL`. Hashes files on disk under
`ORIGINALS_ROOT` to resolve UIDs. Runs **wherever the new PhotoPrism is
reachable and originals are mounted** (homecloud first, dev workstation
for the smoke test).
- `example-snapshot.json` — synthetic input that exercises Phase A + B
against the local sample library. Smoke test:
```sh
PHOTOPRISM_BASE_URL=http://localhost:2342 \
PHOTOPRISM_USER=admin \
PHOTOPRISM_PASSWORD=... \
ORIGINALS_ROOT=$(pwd)/photos-sample \
node migrate/run.mjs --snapshot migrate/example-snapshot.json
```
Add `--phase=sidecars` or `--phase=heaps` to run just one phase, or
`--dry-run` to log without mutating.
## Snapshot schema
```json
{
"photos": [
{
"filepath": "Screenshot_20260510_110411.png",
"user_title": null,
"user_notes": "Trip to Paris",
"rating": 4,
"is_picked": true,
"is_discarded": false,
"is_hidden": false,
"taken_at": "2024-06-15T14:30:00Z",
"tags": ["paris", "trip"]
}
],
"heaps": [
{
"name": "Summer 2024",
"photo_filepaths": ["Screenshot_20260510_110411.png"]
}
]
}
```
- `filepath` is relative to `ORIGINALS_ROOT`.
- `is_hidden` is **dropped** by the migrator per the merge plan.
- `tags` lands in `Details.Keywords` (comma-separated, `KeywordsSrc=manual`).
- Heaps reference photos by `filepath`; the migrator resolves these to
PhotoPrism UIDs after Phase A's reindex finishes.
## What's NOT migrated
- mule-image's `is_hidden` (per the planning round).
- Heap+folder sharing state (planned for the Go sidecar service).
- Undo history.
- Nextcloud per-user roots.
- The 5-star user rating (PhotoPrism's `Rating` field is server-managed in
this build; we map mule-image's `is_picked` boolean → `Favorite`).

View File

@@ -0,0 +1,36 @@
{
"_comment": "Synthetic snapshot exercising the migrator against ./photos-sample. Run from repo root: node migrate/run.mjs --snapshot migrate/example-snapshot.json",
"photos": [
{
"filepath": "Screenshot_20260507_204050.png",
"user_title": "Paris dawn",
"user_notes": "Migration smoke test caption",
"rating": 4,
"is_picked": true,
"is_discarded": false,
"is_hidden": false,
"taken_at": "2024-06-15T07:00:00Z",
"tags": ["paris", "migration-test"]
},
{
"filepath": "Screenshot_20260508_000706.png",
"user_title": null,
"user_notes": "Another mule-image note",
"rating": null,
"is_picked": false,
"is_discarded": false,
"is_hidden": false,
"taken_at": "2024-07-04T18:30:00Z",
"tags": ["fireworks"]
}
],
"heaps": [
{
"name": "M5 migration test",
"photo_filepaths": [
"Screenshot_20260507_204050.png",
"Screenshot_20260508_000706.png"
]
}
]
}

90
migrate/legacy_export.mjs Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
// Dumps a legacy mule-image Postgres into the snapshot JSON shape `run.mjs`
// consumes. Intended to run on the homecloud server where the legacy stack
// lives (since `DATABASE_URL` points at the in-network Postgres there).
//
// DATABASE_URL=postgres://mulita:mulita@db:5432/mulita \
// node migrate/legacy_export.mjs > snapshot.json
//
// Uses the `pg` npm package which the legacy mule-image already ships in
// its backend image; if you're running this from a separate workstation,
// `npm install pg` first.
import pg from 'pg';
import process from 'node:process';
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error('DATABASE_URL required');
process.exit(2);
}
const client = new pg.Client({ connectionString: DATABASE_URL });
await client.connect();
// ── photos ───────────────────────────────────────────────────────────────────
// Mule-image's photos table is the source of all per-file metadata. The
// migrator only reads fields that have a clean PhotoPrism mapping; the
// rest (auto-tags, ML-derived fields, etc.) are skipped per the merge plan.
const photoRows = await client.query(`
SELECT
p.filepath,
p.user_title,
p.user_notes,
p.rating,
p.is_picked,
p.is_discarded,
p.is_hidden,
p.taken_at,
COALESCE(
array_agg(t.name) FILTER (WHERE t.name IS NOT NULL),
'{}'
) AS tags
FROM photos p
LEFT JOIN photo_tags pt ON pt.photo_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE p.is_active IS NULL OR p.is_active = true
GROUP BY p.id
`);
// ── heaps + memberships ─────────────────────────────────────────────────────
const heapRows = await client.query(`
SELECT h.id, h.name FROM heaps h WHERE h.is_active = true
`);
const heapMembers = await client.query(`
SELECT hp.heap_id, p.filepath
FROM heap_photos hp
JOIN photos p ON p.id = hp.photo_id
`);
const membersByHeap = new Map();
for (const r of heapMembers.rows) {
if (!membersByHeap.has(r.heap_id)) membersByHeap.set(r.heap_id, []);
membersByHeap.get(r.heap_id).push(r.filepath);
}
// ── assemble ─────────────────────────────────────────────────────────────────
const snapshot = {
photos: photoRows.rows.map((r) => ({
filepath: r.filepath,
user_title: r.user_title,
user_notes: r.user_notes,
rating: r.rating,
is_picked: Boolean(r.is_picked),
is_discarded: Boolean(r.is_discarded),
is_hidden: Boolean(r.is_hidden),
taken_at: r.taken_at ? new Date(r.taken_at).toISOString() : null,
tags: r.tags ?? []
})),
heaps: heapRows.rows.map((h) => ({
name: h.name,
photo_filepaths: membersByHeap.get(h.id) ?? []
}))
};
process.stdout.write(JSON.stringify(snapshot, null, 2));
await client.end();

257
migrate/run.mjs Normal file
View File

@@ -0,0 +1,257 @@
#!/usr/bin/env node
// Two-phase migration: legacy snapshot → PhotoPrism (metadata + heaps).
//
// Phase A — for each legacy photo, hash the on-disk file, resolve the
// PhotoPrism UID via `q=hash:<sha1>`, then PUT the merged metadata onto
// `/api/v1/photos/:uid`. We don't write sidecars directly because
// PhotoPrism's reindex rewrites them from its DB state — PUT is the only
// authoritative path. Slower (one API call per photo) but reliable.
//
// Phase B — creates a manual PhotoPrism Album for each legacy heap and
// bulk-adds the resolved UIDs via `/albums/:uid/photos`.
//
// Both phases are idempotent. Re-running Phase A merges the same fields
// (PhotoPrism's PUT does a deep-merge for nested Details). Phase B looks
// up heaps by title and only creates if absent.
import crypto from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
// ── env / cli ────────────────────────────────────────────────────────────────
const PP_BASE = process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342';
const PP_USER = process.env.PHOTOPRISM_USER ?? 'admin';
const PP_PW = process.env.PHOTOPRISM_PASSWORD;
const ORIGINALS_ROOT = path.resolve(
process.env.ORIGINALS_ROOT ?? '/photoprism/originals'
);
const STORAGE_ROOT = path.resolve(
process.env.STORAGE_ROOT ?? '/photoprism/storage'
);
if (!PP_PW) {
console.error('PHOTOPRISM_PASSWORD is required');
process.exit(2);
}
const args = parseArgs(process.argv.slice(2));
if (!args.snapshot) {
console.error('--snapshot <path> required');
process.exit(2);
}
const phase = args.phase ?? 'all';
if (!['sidecars', 'heaps', 'all'].includes(phase)) {
console.error(`unknown --phase=${phase} (sidecars|heaps|all)`);
process.exit(2);
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--snapshot') out.snapshot = argv[++i];
else if (a === '--phase') out.phase = argv[++i];
else if (a === '--dry-run') out.dryRun = true;
}
return out;
}
// ── http helpers ─────────────────────────────────────────────────────────────
let TOKEN = null;
async function pp(method, urlPath, body) {
const init = {
method,
headers: {
'Content-Type': 'application/json',
...(TOKEN ? { 'X-Auth-Token': TOKEN } : {})
}
};
if (body !== undefined) init.body = JSON.stringify(body);
const r = await fetch(new URL(urlPath, PP_BASE), init);
const text = await r.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!r.ok) {
const msg = data?.error ?? data?.message ?? `HTTP ${r.status}`;
throw new Error(`${method} ${urlPath}${msg}`);
}
return data;
}
async function login() {
const data = await pp('POST', '/api/v1/session', {
username: PP_USER,
password: PP_PW
});
TOKEN = data.access_token;
console.log(`logged in as ${data.user.Name}`);
}
// ── hashing ──────────────────────────────────────────────────────────────────
async function sha1(absPath) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha1');
const s = createReadStream(absPath);
s.on('data', (c) => h.update(c));
s.on('end', () => resolve(h.digest('hex')));
s.on('error', reject);
});
}
// ── Phase A: PUT merged metadata per photo ───────────────────────────────────
function buildPatch(photo) {
const patch = {};
if (photo.user_title) {
patch.Title = photo.user_title;
patch.TitleSrc = 'manual';
}
if (photo.user_notes) {
patch.Caption = photo.user_notes;
patch.CaptionSrc = 'manual';
}
// mule-image's is_picked (binary "starred") → PhotoPrism Favorite.
// is_discarded → Archived. Rating + is_hidden intentionally not
// migrated per the merge plan.
if (photo.is_picked) patch.Favorite = true;
if (photo.is_discarded) patch.Archived = true;
if (photo.taken_at) {
const d = new Date(photo.taken_at);
if (!Number.isNaN(d.getTime())) {
const iso = d.toISOString().replace(/\.\d+Z$/, 'Z');
patch.TakenAt = iso;
patch.TakenAtLocal = iso;
patch.TakenSrc = 'manual';
patch.Year = d.getUTCFullYear();
patch.Month = d.getUTCMonth() + 1;
patch.Day = d.getUTCDate();
}
}
if (Array.isArray(photo.tags) && photo.tags.length) {
patch.Details = {
Keywords: photo.tags.join(', '),
KeywordsSrc: 'manual'
};
}
return patch;
}
async function resolveUid(filepath) {
const abs = path.join(ORIGINALS_ROOT, filepath);
const exists = await stat(abs).then(() => true, () => false);
if (!exists) return null;
const hash = await sha1(abs);
const r = await pp(
'GET',
`/api/v1/photos?count=1&q=${encodeURIComponent(`hash:${hash}`)}`
);
const match = Array.isArray(r) ? r[0] : null;
return match ? match.UID : null;
}
async function runPhaseA(snapshot) {
console.log(`phase A — ${snapshot.photos.length} photos`);
let updated = 0;
let skipped = 0;
let missing = 0;
for (const p of snapshot.photos) {
const patch = buildPatch(p);
if (Object.keys(patch).length === 0) {
skipped++;
continue;
}
const uid = await resolveUid(p.filepath);
if (!uid) {
console.warn(` [skip] ${p.filepath} — no matching PhotoPrism photo`);
missing++;
continue;
}
if (args.dryRun) {
console.log(` [dry] ${p.filepath}${uid}`);
updated++;
continue;
}
// PhotoPrism PUT requires the full photo body for nested Details to
// stick — fetch first, deep-merge the patch in, then PUT.
const current = await pp('GET', `/api/v1/photos/${uid}`);
const merged = { ...current, ...patch };
if (patch.Details) {
merged.Details = { ...(current.Details ?? {}), ...patch.Details };
}
await pp('PUT', `/api/v1/photos/${uid}`, merged);
console.log(` patched ${p.filepath} (${uid})`);
updated++;
}
console.log(`phase A — updated ${updated}, no-data ${skipped}, missing ${missing}`);
}
// ── Phase B: heaps ───────────────────────────────────────────────────────────
async function findHeapByTitle(title) {
const r = await pp(
'GET',
`/api/v1/albums?type=album&count=500&q=${encodeURIComponent(title)}`
);
const arr = Array.isArray(r) ? r : [];
return arr.find((a) => a.Title === title) ?? null;
}
async function runPhaseB(snapshot) {
console.log(`phase B — ${snapshot.heaps.length} heaps`);
for (const heap of snapshot.heaps) {
const existing = await findHeapByTitle(heap.name);
let album;
if (existing) {
console.log(` heap "${heap.name}" exists (${existing.UID})`);
album = existing;
} else if (args.dryRun) {
console.log(` [dry] create heap "${heap.name}"`);
continue;
} else {
album = await pp('POST', '/api/v1/albums', {
Title: heap.name,
Type: 'album'
});
console.log(` created heap "${heap.name}" (${album.UID})`);
}
const uids = [];
for (const fp of heap.photo_filepaths) {
const uid = await resolveUid(fp);
if (uid) uids.push(uid);
else console.warn(` [skip] ${fp} — no matching PhotoPrism photo`);
}
if (!uids.length) {
console.log(` heap "${heap.name}" — nothing to add`);
continue;
}
if (args.dryRun) {
console.log(` [dry] add ${uids.length} to "${heap.name}"`);
continue;
}
const r = await pp('POST', `/api/v1/albums/${album.UID}/photos`, { photos: uids });
console.log(` added ${uids.length} to "${heap.name}"`);
void r;
}
}
// ── main ─────────────────────────────────────────────────────────────────────
const snapshot = JSON.parse(await readFile(args.snapshot, 'utf8'));
await login();
if (phase === 'sidecars' || phase === 'all') await runPhaseA(snapshot);
if (phase === 'heaps' || phase === 'all') await runPhaseB(snapshot);
console.log('done');