Culling actions used to operate on a single photo (the activePhotoId)
even when many were selected — pressing 5 with ten thumbnails high-
lighted only rated one. Same for the RightSidebar buttons, which
weren't even visible in multi-select mode. Lightroom semantics: every
culling action applies to the whole selection.
Fix
- Three new bulk helpers in services/api.ts:
photos.bulkSetRating(ids, rating)
photos.bulkSetColor(ids, color | null)
(existing photos.bulkDiscard / bulkRestore reused for X / U)
All matching the backend BulkAction { ids, action, value } shape
the /photos/bulk endpoint already accepts.
useKeyboardShortcuts
- New cullTargets() helper: selectedPhotos if non-empty, else
activePhotoId in a singleton, else empty.
- updateActive() now branches on cullTargets().length:
1 → existing PATCH /photos/{id} path (single-photo).
2+ → fans out to the right bulk endpoint per field. rating goes
to bulkSetRating, color_label to bulkSetColor, is_discarded
to bulkDiscard / bulkRestore.
- 1-5 / 0 / X / U / 6-9 shortcuts now Just Work on multi-select
without further changes — they all funnel through updateActive.
RightSidebar
- Restructured the Quick Actions block: filename / title / notes are
hidden in multi-select (they only make sense for one photo); but
rating / color / flag controls are now always visible when at
least one photo is selected. A small "Rating, color, and flag
apply to all N selected" hint shows in multi mode.
- New applyRating / setColor / applyDiscard helpers fan out to the
bulk endpoints when selectedPhotos.length > 1, otherwise hit the
per-photo PATCH path. The displayed value still reflects the
active photo (last clicked) so the user has a visual anchor —
matches Lightroom's "focused vs selected" model.
- Pick/Heap-toggle button is now selection-aware too: heapMutation
takes ids[], the click handler reads selectedPhotos, and the
add-vs-remove decision uses "every selected is a member" exactly
like the P keyboard shortcut. Optimistic membership cache update
also flips the basket badge across all selected thumbnails
instantly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mulita - Self-Hosted Photo Management Application
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 search
- 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
- Dark Mode: Photography-optimized dark interface
Tech Stack
Backend
- Python 3.12 with FastAPI
- SQLite with SQLAlchemy (async)
- Celery + Redis for background tasks
- pyvips for fast thumbnail generation
- ExifTool for metadata extraction
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)
- Clone the repo:
git clone <repository-url>
cd muleimage
-
Set one environment variable in
.env— the host directory that contains your photo library. Whatever you point at will become your library inside Mulita.# macOS / Linux PHOTO_DIRS=/Users/you/Pictures # or any folder PHOTO_DIRS=/mnt/nas/photos # Windows (WSL) PHOTO_DIRS=/mnt/c/Users/you/Pictures -
Start the stack:
docker compose up -d
- Open
http://localhost:3000. On first boot Mulita will:- Mount your
PHOTO_DIRSat/photosinside the container - Auto-create a source root called Library pointing at
/photos - Queue an initial scan, generate thumbnails, and start serving them
- Mount your
You don't need to touch mulita.yml or the API to get started.
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:
- Edit
PHOTO_DIRSin.env docker compose down- (Optional, for a clean slate)
docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data 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:
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 newfilename(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.
Architecture
The application consists of 5 Docker services:
- frontend: React SPA served by Nginx
- backend: FastAPI REST API
- worker: Celery workers for background tasks
- redis: Message broker for Celery
- db: SQLite database (file-based)
Keyboard Shortcuts
| Key | Action |
|---|---|
← → ↑ ↓ |
Navigate photos |
Space |
Quick preview |
Enter |
Open loupe view |
P |
Pick photo |
X |
Reject photo |
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
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload
Frontend Development
cd frontend
npm install
npm run dev
Configuration
Source roots are managed by the UI / API (the database owns them). Edit
mulita.yml to configure operational settings only:
- Thumbnail sizes, quality, and format
- Scanner behaviour (watch, batch size, initial scan)
- Performance tuning (concurrency, cache TTLs, DB pool)
Performance
- Handles 100,000+ photos efficiently
- Virtual scrolling for smooth timeline navigation
- Thumbnail generation at 10+ photos/second
- SQLite FTS5 for fast full-text search
Future Features (Phase 2)
- AI-powered scene classification
- Face detection and clustering
- Smart albums
- Duplicate detection
- Export presets
- Multi-user support
License
MIT