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>
258 lines
8.2 KiB
JavaScript
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');
|