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>
This commit is contained in:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

97
migrate/README.md Normal file
View File

@@ -0,0 +1,97 @@
# 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:<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:
```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`).