Files
mule-image/sidecar
dtoro 246d159d93 feat(metadata,people): richer metadata editing + face-naming flow
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>
2026-07-04 18:35:27 +02:00
..

mule-sidecar

Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not expose. Same wire contract as the M3 Node prototype it replaces; the SvelteKit client at web/ talks to it transparently through Vite's /api/sidecar/* proxy.

What it owns

Method Path Purpose
GET /api/sidecar/healthz Unauthenticated liveness probe.
GET /api/sidecar/photos/marks Every per-photo {rating, color} mark.
GET /api/sidecar/photos/:uid/marks One photo's mark (or {} if none).
PUT /api/sidecar/photos/:uid/marks Patch one photo's mark.
POST /api/sidecar/photos/marks/bulk Stamp the same mark onto many photos.
POST /api/sidecar/files/:uid/rename Rename the primary file on disk + reindex.
POST /api/sidecar/folders Create a folder under ${ORIGINALS_ROOT}.
POST /api/sidecar/folders/:rel/rename Rename a folder (rel path URL-encoded).
DELETE /api/sidecar/folders/:rel Delete an empty folder.
POST /api/sidecar/albums/:uid/convert Move/copy every photo in a heap into folder X.
GET /api/sidecar/duplicates/scan Walk originals, return same-hash groups.
POST /api/sidecar/duplicates/archive Move duplicate paths into .duplicates/<ts>/.

Auth: every endpoint except healthz requires the caller's X-Auth-Token header. The sidecar holds no service credentials — it proxies the token straight back to PhotoPrism's /api/v1/photos?count=1 to confirm the session is live before doing anything destructive.

Marks persist to MariaDB (mule_sidecar.marks); everything else operates on the filesystem under ${ORIGINALS_ROOT} and triggers a PhotoPrism reindex of the affected parent in the background.

Run

The sidecar is a sidecar service in the PhotoPrism compose stack. Bringing the whole stack up brings it up too:

podman-compose --env-file .env \
  -f docker-compose.yml \
  -f docker-compose.podman.yml \
  up -d

This builds Dockerfile (multi-stage golang:1.25-alpinegcr.io/distroless/static, ~12 MB final image), starts the container, and binds 127.0.0.1:8000 to the service. The SvelteKit dev server proxies /api/sidecar/* to that port transparently.

Dev-iteration loop (host build)

For tight iteration without rebuilding the image on every change you can run it as a host process — Go is already on the dev machine:

cd sidecar
go build -o mule-sidecar .

ORIGINALS_ROOT=/path/to/photoprism/originals \
PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \
./mule-sidecar

The host build connects to mariadb via the loopback port the compose file publishes; stop pp-sidecar first so they don't fight for 8000.

Env

Var Default Notes
ORIGINALS_ROOT /photoprism/originals Absolute path; must match PhotoPrism's mount.
PHOTOPRISM_BASE_URL http://localhost:2342 Where to reach PhotoPrism for session validation + reindex calls.
SIDECAR_PORT 8000 Loopback-only; reverse-proxy fronts it in production.
SIDECAR_DSN (built from the vars below) Set this to override the assembled MySQL DSN entirely.
SIDECAR_DB_HOST 127.0.0.1 Host of the MariaDB the compose stack publishes on 127.0.0.1:3306.
SIDECAR_DB_PORT 3306
SIDECAR_DB_USER sidecar Provisioned by mariadb/init/01-sidecar.sql on first boot.
SIDECAR_DB_PASSWORD replace-at-m4-bringup Literal placeholder — rotate before any non-local deployment.
SIDECAR_DB_NAME mule_sidecar

Schema

GORM AutoMigrate creates the only table the service owns:

CREATE TABLE marks (
  photo_uid  VARCHAR(64) PRIMARY KEY,
  rating     BIGINT NULL,
  color      VARCHAR(16) NULL,
  updated_at DATETIME(3)
);

The M3 Node prototype kept the same data in sidecar/data/marks.json. There is no migration path — the prototype's marks file was dev-only state. Heap-sharing tables (M4) will land in subsequent migrations.

Layout

sidecar/
├── Dockerfile            multi-stage golang:1.25 → distroless/static
├── main.go               entrypoint, route wiring, graceful shutdown
├── config.go             env-driven Config
├── db.go                 GORM open + Mark model + AutoMigrate
├── auth.go               requireSession middleware + ctxToken
├── fs.go                 path safety, walk, sha1
├── pp.go                 PhotoPrism HTTP client (validateSession, reindex)
├── handlers_rename.go
├── handlers_folders.go
├── handlers_marks.go
├── handlers_heap.go
└── handlers_dups.go