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