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:
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