# 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:`, 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`).