Files
mule-image/migrate/run.mjs
dtoro 8c2526d982 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>
2026-05-17 16:06:58 +02:00

258 lines
8.2 KiB
JavaScript

#!/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');