The frontend AddSourceFolderDialog let users register source roots from inside the app, but with the bootstrap auto-creating one for the /photos mount on first boot, the dialog was redundant in the common case and confusing in every other (users had to know which container path corresponded to their host directory). Going config-driven matches Plex/Photoprism/Immich and matches the mental model "the docker mount IS the library". Frontend - Deleted components/dialogs/AddSourceFolderDialog.tsx entirely. - LeftSidebar drops the "+ Add Source Folder" button + bottom-bar layout, the addFolderMutation, the dead Plus action button on the (no-longer-existing) folders/heaps tree headers, and the Plus icon import. - api.ts: removed sourceFolders.add(), library.browse(), and the BrowseChild / BrowseResponse types. The remaining sourceFolders surface is read-only (list + manual scan). - LeftSidebar bottom strip is now just the "Scan all folders" button when there's at least one source root. Backend - Dropped POST /folders (no consumers) along with FolderCreate / FolderResponse pydantic models. The folders router header now documents the config-driven approach. - Dropped GET /library/browse (no consumers). Removed the unused os/HTTPException/SourceRoot imports it brought in. - cleanup_data_integrity now also walks the source roots and logs a warning for any whose path is missing on disk. Doesn't auto- delete (a missing path could be a temporarily unmounted drive) but surfaces enough hint to fix it. Returns the count in the summary dict alongside merged-duplicates. Docs - README "How libraries are managed" section rewritten to spell out that mounts ARE source roots, edit .env + restart, no UI for managing source roots. New "Changing or adding libraries" section walks through the typical edit-restart loop including the optional volume-nuke for a clean slate. - "Adding more libraries" subsection covers multi-mount via edited compose with a note that auto-registration is roadmap. 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