Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
fixing videoFile(): PhotoPrism serializes MediaType as the bare word
"video", so the old startsWith('video/') check never matched and the
helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
(per-photo in bulk edits) so PhotoPrism can't clobber manual date
edits when recomputing from TimeZone, and Year/Month/Day now derive
from local wall-clock time.
People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
markers→files→photos SQL pass (labels pattern), replacing the
client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
{Name, SubjSrc:manual}, verified against PP source) which creates
the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
per-marker ownership checks (was blanket-forbidden, which would have
blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
linking to the person's page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mule-image
Self-hosted photo management built on top of PhotoPrism. A SvelteKit
frontend (web/) plus a small Go service (sidecar/)
fill in the keyboard-driven UI and the file/folder/mark endpoints
PhotoPrism's REST API does not expose. PhotoPrism itself handles
indexing, originals, thumbnails, and the database; we never re-implement
those.
Architecture
┌──────────────────┐ /api/v1/* ┌──────────────┐
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
│ │ ─────────┐ └──────────────┘
└──────────────────┘ ▼
┌──────────────┐
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
│ :8000 │ ──▶ originals FS (rename / folders / dups)
└──────────────┘
Three compose services — mariadb, photoprism, sidecar — plus the
SvelteKit web/ app served separately. PhotoPrism's port 2342 is
bound to 127.0.0.1 only; it isn't a user-facing surface. The
SvelteKit app is.
What the sidecar adds on top of PhotoPrism (full list in
sidecar/README.md):
- Per-photo marks (rating + color) persisted to
mule_sidecar.marks - File rename + folder create/rename/delete with PhotoPrism reindex
- Heap (album) → folder conversion
- Perceptual-hash duplicate scan + archive
Quick start
cp .env.example .env
# edit .env: set PHOTO_DIRS to the host path holding your library
# rotate PP_ADMIN_PASSWORD, PP_DB_PASSWORD, PP_DB_ROOT_PASSWORD
# before any non-local deployment.
podman-compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.podman.yml \
up -d
Then serve the frontend. For local use the simplest path is the Vite dev server:
cd web
npm install
npm run dev
# open http://localhost:5173
For a static deployment, npm run build produces a bundle under
web/build/ that any static file host (nginx, Caddy, GitHub Pages-style)
can serve. Reverse-proxy /api/v1/* to http://127.0.0.1:2342 and
/api/sidecar/* to http://127.0.0.1:8000.
PhotoPrism's own UI is still reachable from the host at
http://127.0.0.1:2342 if you need admin features (user management,
settings) — set up an SSH tunnel from your laptop if the server is
remote.
Configuration
All knobs live in .env.example. The required ones:
| Variable | Notes |
|---|---|
PHOTO_DIRS |
Host path mounted at /photoprism/originals. The library. |
PP_ADMIN_PASSWORD |
First-boot admin password. Rotate. |
PP_DB_PASSWORD |
MariaDB password for the photoprism user. Rotate. |
PP_DB_ROOT_PASSWORD |
MariaDB root password. Rotate. |
PP_UID / PP_GID |
Host UID/GID that owns PHOTO_DIRS. PhotoPrism + sidecar drop to this user inside. |
PP_PORT |
Loopback host port for PhotoPrism (default 2342). |
PP_ORIGINALS_MODE |
rw (default) or ro — see Read-only libraries. |
SIDECAR_PORT |
Loopback host port for the sidecar (default 8000). |
Sidecar-specific env (DB DSN, USER_BASEPATHS, etc.) is documented in
sidecar/README.md.
Read-only libraries
The default originals mount is :rw because file operations (rename,
folder mutations, duplicate archive, heap convert) need to mutate the
filesystem. To run against a read-only archive, set
PP_ORIGINALS_MODE=ro in .env. Browsing, marks, ratings, and color
labels still work; the following sidecar endpoints return an OS error:
POST /api/sidecar/files/:uid/renamePOST /api/sidecar/folders/:rel/rename/DELETE /:relPOST /api/sidecar/albums/:uid/convertPOST /api/sidecar/duplicates/archive
PhotoPrism's PHOTOPRISM_READONLY is controlled separately by
PP_READONLY and gates its own backwrite / import paths.
Mobile & third-party apps (per-user)
PhotoPrism CE does not enforce auth_users.base_path on API reads —
any authenticated user can search the whole library. The sidecar
therefore ships a scoping proxy at /api/v1/* (see
sidecar/handlers_ppproxy.go) and the
reverse proxy routes the public /api/v1 there instead of straight to
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
sees only the logged-in user's photos.
- Server URL for apps: the site itself (e.g.
https://photos.hubris.network). Known-good client: Gallery for PhotoPrism (Android/F-Droid). - Login: the user's normal username/password. For OIDC accounts (no
password), mint an app password:
docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>and use it as the password in the app. - What's scoped: photo/geo searches, per-photo reads and edits, batch operations, downloads by UID. Hash-addressed media (thumbnails, video streams, file downloads) is token-guarded and passes through.
- What's shared (CE has no per-user variants of these): album names, labels, and people — the photos inside them stay scoped. Album zip downloads are generated by PhotoPrism and are not scoped.
- Uploads: the reconciler mirrors
base_pathintoupload_path, so WebDAV/app uploads land inside the user's own subtree. - Sessions with the
adminrole bypass the proxy scoping entirely (the web client's settings/users/index dialogs need the raw API).
Dev iteration loop
For fast iteration on the sidecar without rebuilding its image on every
change, run it as a host process — bring up just mariadb and
photoprism from compose, then build and run the Go binary locally.
Full instructions in sidecar/README.md.
Layout
.
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
├── .env.example required env vars (copy to .env)
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
├── pp/ PhotoPrism bind-mounted state (storage, import)
├── sidecar/ Go service — see sidecar/README.md
└── web/ SvelteKit frontend
GPU video acceleration (optional)
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
docker-compose.gpu.yml to hand /dev/dri/*
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
perf win for video thumbnails and HEVC→H.264 transcodes:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
Set PP_FFMPEG_ENCODER=vaapi in .env (default for the overlay). Verify
with docker exec pp-app photoprism show config | grep -i ffmpeg.