dtoro c6f31b5dfb feat(move): undoable moves + Lightroom-style move/copy dialog
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>
2026-07-05 09:56:21 +02:00

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/rename
  • POST /api/sidecar/folders / :rel/rename / DELETE /:rel
  • POST /api/sidecar/albums/:uid/convert
  • POST /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_path into upload_path, so WebDAV/app uploads land inside the user's own subtree.
  • Sessions with the admin role 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.

Description
No description provided
Readme 13 MiB
Languages
Svelte 54.6%
TypeScript 26.5%
Go 18.2%
CSS 0.4%
Dockerfile 0.1%
Other 0.1%