Backend correctness was already sound (companion files travel as a
group, coordinated collision suffixes, EXDEV fallback, per-file scope
checks, blocking scoped reindex) — this pass adds reversibility and
brings the modal up to standard.
Sidecar:
- movePhotoFiles records per-file {from,to} pairs (move mode) —
including siblings of photos that failed partway, since undo must
restore whatever actually left its folder. Both POST /photos/move
and POST /albums/:uid/convert return them as movedFiles.
- New POST /files/restore-moves plays those pairs backwards: both ends
scope-checked (sources aren't quarantined like the duplicates
restore), never clobbers an existing destination, EXDEV fallback,
blocking reindex of affected parents so the client's refetch already
sees the restored layout.
Dialog (all three subjects — photos, heap convert, folder reparent):
- Search field on top (autofocused) filtering the tree live: matches +
ancestors, force-expanded without touching the sidebar's persisted
open/collapse state (new FolderTree forceExpand prop).
- Arrow keys rove through visible rows with selection following focus
(data-move-row attributes in FolderTree's readonly picker mode);
Enter confirms from anywhere once a destination is set.
- Recent destinations as one-click chips (last 5, per library base).
- Live destination preview line and count-labeled confirm buttons
("Move 12 photos", "Move “2024”") with a disabled-reason tooltip.
- Client-side subfolder validation mirroring the sidecar's
sanitizeFilename rules (inline error, aria-invalid, confirm gated).
- Pre-disables Move when every selected photo is already in the target.
- Undo everywhere it's safe: photo/heap moves restore via the new
endpoint, folder moves invert to another folder move, copies stay
toast-only (their inverse would be deletion). Success toasts carry
an inline Undo action; ⌘Z works through the shared undo stack.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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-alpine →
gcr.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