dtoro bed817d274 feat: heap convert-to-folder + surface exact-duplicate detection
Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:28:18 +02:00
2026-04-06 23:30:19 +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 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)

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

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
  • 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

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%