Files
mule-image/migrate/README.md
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

4.0 KiB

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.

┌────────────────────┐    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:<sha1>, 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:

    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

{
  "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).