repo cleanup: retire legacy mule-image stack, lock PhotoPrism UI to loopback

Delete the old Python+React mule-image stack (backend/, frontend/,
docker-compose.yml, mulita.yml, .env*) plus the one-shot migration and
sample dirs (migrate/, photos-sample/, photovault-app-prompt.md). Only
the PhotoPrism + Go sidecar + SvelteKit web stack remains, so drop the
".photoprism." qualifier from the compose+env filenames.

Bind PhotoPrism's port to 127.0.0.1 so the user-facing surface is just
the SvelteKit web/ app; admin reaches PP's UI via SSH tunnel. Flatten
PHOTOPRISM_INDEX_WORKERS' nested default (podman-compose's interpolator
doesn't expand ${A:-${B:-…}}). Rewrite README for the current stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:30:38 +02:00
parent 155e9bb126
commit 9bba097d91
200 changed files with 368 additions and 39840 deletions

315
README.md
View File

@@ -1,230 +1,125 @@
# Mulita - Self-Hosted Photo Management Application
# mule-image
A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. Mulita provides a fast, keyboard-driven interface to browse, organize, tag, and manage your photo library.
## Features
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and GPS mapping
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
- **Heaps**: Temporary collections for organizing photos
- **Tags & Ratings**: Organize with tags, star ratings, and color labels — each with a card-grid browse view that drills into a full Timeline detail
- **Dark Mode**: Photography-optimized dark interface
- **Vision Pipeline**: YOLO object detection, OCR text extraction, CLIP embeddings for semantic search, InsightFace face detection and clustering
- **People View**: Browse identified people as cards, click to see all photos of a person
- **Map View**: Browse GPS-tagged photos on an interactive Leaflet map
- **Duplicate Detection**: Perceptual hash-based duplicate grouping with best-pick UI
- **Semantic Search**: Natural-language photo search powered by CLIP embeddings
## Tech Stack
### Backend
- Python 3.12 with FastAPI
- PostgreSQL + pgvector with SQLAlchemy (async) and Alembic migrations
- Celery + Redis for background tasks
- pyvips for fast thumbnail generation
- ExifTool for metadata extraction
- ONNX Runtime for vision models (YOLO, CLIP, InsightFace)
### Frontend
- React 18 with TypeScript
- Vite for fast development
- TanStack Query for data fetching
- TanStack Virtual for virtualized scrolling
- Tailwind CSS for styling
- Zustand for state management
## Quick Start
### Prerequisites
- Docker and Docker Compose
### Setup (one variable)
1. Clone the repo:
```bash
git clone <repository-url>
cd muleimage
```
2. Copy the example env file and set **one** variable — the **host**
directory that contains your photo library. Whatever you point at
will become your library inside Mulita.
```bash
cp .env.example .env
# then edit .env and set PHOTO_DIRS:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# Network share: PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
```
3. Start the stack:
```bash
docker compose up -d
```
4. Open `http://localhost:3000`. On first boot Mulita will:
- Mount your `PHOTO_DIRS` at `/photos` inside the container
- Auto-create a source root called **Library** pointing at `/photos`
- Queue an initial scan, generate thumbnails, and start serving them
You don't need to touch `mulita.yml` or the API to get started.
### Configuration knobs
Everything is environment-driven. `PHOTO_DIRS` is the only required
value; the rest have sensible defaults documented in `.env.example`:
| Variable | Default | Notes |
|----------------------|---------|----------------------------------------------------|
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
### Accessing from another machine
The frontend talks to the backend through its bundled nginx, which
proxies `/api/` to the backend on the internal compose network. That
means requests are always **same-origin** as the page, so accessing
Mulita from another host works without any CORS dance:
```
http://<your-server-ip>:3000
```
If you want to put it behind a reverse proxy at e.g.
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
requests if anything bypasses the proxy.
### How libraries are managed
Mulita is **config-driven**: the host directory you mount via
`PHOTO_DIRS` becomes your library, and the backend automatically
registers it as a source root on startup. There is no UI for adding
or removing source roots — to change what Mulita scans, edit `.env`
(or `docker-compose.yml` for multi-mount setups) and restart the
stack.
This keeps the model simple: **the docker mount IS the library**.
No two layers, no confusion about which view to use.
### Changing or adding libraries
To point at a different library:
1. Edit `PHOTO_DIRS` in `.env`
2. `docker compose down`
3. (Optional, for a clean slate) `docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data`
4. `docker compose up -d`
The new library shows up automatically. Without step 3 the old
library's metadata stays in the DB and you'll see a warning at
startup that the old source root's path is missing on disk —
that's a hint to clean up.
For multiple libraries, edit `docker-compose.yml` and add additional
mount lines:
```yaml
volumes:
- ${PHOTO_DIRS}:/photos:rw
- /Volumes/Archive:/archive:rw # additional library
```
Each mounted directory will need a corresponding source root row in
the DB; today that means `POST /api/v1/folders` via curl, or wait
for the multi-mount auto-registration that's on the roadmap.
### Read-only libraries
The default mount is `:rw` because file operations (rename, move,
empty discard pile) need to mutate the filesystem. If you want a
strict read-only library — pointing at a network share, an
authoritative archive, etc. — flip `:rw` to `:ro` in
`docker-compose.yml`. Mulita will keep working for browsing, rating,
color labels, picks, heaps, and the (soft) discard flag, but the
following will return an OS error:
- `PATCH /photos/{id}` with a new `filename` (rename)
- `POST /photos/move` (bulk move)
- `DELETE /discard/empty` (file unlinks)
**Heads up**: with `:rw`, Mulita has full write access to whatever
host directory you mount. Treat the same way you would Lightroom's
catalog folder.
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](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
The application consists of 5 Docker services:
- **frontend**: React SPA served by Nginx
- **backend**: FastAPI REST API
- **worker**: Celery workers for background tasks (thumbnails, metadata, vision pipeline)
- **redis**: Message broker for Celery
- **db**: PostgreSQL with pgvector extension (for CLIP/face embeddings)
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `` `` `` `` | Navigate photos |
| `Space` | Quick preview |
| `Enter` | Open loupe view |
| `T` | Add to active heap |
| `1-5` | Set star rating |
| `Tab` | Toggle left sidebar |
| `I` | Toggle metadata panel |
| `G` | Grid view |
| `E` | Loupe view |
| `Delete` | Move to trash |
## Development
### Backend Development
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload
```text
┌──────────────────┐ /api/v1/* ┌──────────────┐
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
│ │ ─────────┐ └──────────────┘
└──────────────────┘ ▼
┌──────────────┐
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
│ :8000 │ ──▶ originals FS (rename / folders / dups)
└──────────────┘
```
### Frontend Development
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`](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
```bash
cd frontend
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:
```bash
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
Source roots are managed by the UI / API (the database owns them). Edit
`mulita.yml` to configure operational settings only:
All knobs live in [`.env.example`](.env.example). The required ones:
- Thumbnail sizes, quality, and format
- Scanner behaviour (watch, batch size, initial scan)
- Performance tuning (concurrency, cache TTLs, DB pool)
| 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](#read-only-libraries). |
| `SIDECAR_PORT` | Loopback host port for the sidecar (default `8000`). |
## Performance
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
[`sidecar/README.md`](sidecar/README.md).
- Handles 100,000+ photos efficiently
- Virtual scrolling for smooth timeline navigation
- Thumbnail generation at 10+ photos/second
- PostgreSQL full-text search with tsvector indexing
- pgvector for fast nearest-neighbor embedding search
## Read-only libraries
## Future Features
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:
- Smart albums (auto-populated by saved filters)
- Export presets
- Multi-user support
- `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`
## License
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
`PP_READONLY` and gates its own backwrite / import paths.
MIT
## 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`](sidecar/README.md#dev-iteration-loop-host-build).
## Layout
```text
.
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
├── .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
```
[pp]: https://photoprism.app/