Claudio 7a1c6b618b fix(compose): propagate SECRET_KEY to all workers, not just backend
The workers couldn't decrypt users.nextcloud_app_password_enc because
SECRET_KEY wasn't in their env. _credentials_for() then raised
NextcloudCredentialsMissing and our code swallowed it as "no NC
auth → fall back to local path."

Surfaced on the Phase 4 deploy when /data/thumbs/.../medium.webp
was purged and the vision worker had no disk fallback left. NC
preview fetch then returned None, the classifier got no image,
and the photo failed to classify.

Also masked Phase 3 silently — extract_metadata in worker-light
was falling back to ExifTool every time instead of hitting Memories
(which would have been fine because ExifTool produces the same
fields, but slower and unnecessary). With SECRET_KEY available, the
Memories primary path actually fires.
2026-05-11 13:56:31 +02:00
2026-04-06 23:30:19 +02:00

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 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:
git clone <repository-url>
cd muleimage
  1. 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.

    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
    
  2. Start the stack:

docker compose up -d
  1. 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:

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.

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

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
  • PostgreSQL full-text search with tsvector indexing
  • pgvector for fast nearest-neighbor embedding search

Future Features

  • Smart albums (auto-populated by saved filters)
  • Export presets
  • Multi-user support

License

MIT

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%