Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.photoprism \
-f docker-compose.photoprism.yml \
-f docker-compose.photoprism.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