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:
97
migrate/README.md
Normal file
97
migrate/README.md
Normal 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`).
|
||||
36
migrate/example-snapshot.json
Normal file
36
migrate/example-snapshot.json
Normal 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
90
migrate/legacy_export.mjs
Normal 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
257
migrate/run.mjs
Normal 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');
|
||||
Reference in New Issue
Block a user