# PhotoVault — Full Application Spec Prompt > A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. > Use this document as the complete specification to build the app from scratch. --- ## 1. Project Overview Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase. --- ## 2. Stack & Deployment ### 2.1 Docker Compose (single `docker-compose.yml`) ``` services: frontend — React SPA (Nginx) backend — Python FastAPI db — SQLite (file-based, volume-mounted) worker — Celery + Redis for background thumbnail/indexing tasks redis — Redis (Celery broker) ``` All services declared in one `docker-compose.yml`. Use named volumes for: - `/data/thumbs` — generated thumbnails (persistent) - `/data/db` — SQLite database file - `/data/trash` — files moved to trash Photo source folders are mounted as **read-write** bind mounts via an environment variable: ```yaml volumes: - ${PHOTO_DIRS}:/photos:rw ``` `PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4. ### 2.2 Frontend - **React 18** + **Vite** - **Tailwind CSS v4** - **shadcn/ui** component library - **TanStack Query** (React Query) for data fetching & cache - **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos) - **Zustand** for global UI state (selection, active photo, heap, filters) - **Framer Motion** for transitions ### 2.3 Backend - **Python 3.12 + FastAPI** - **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations - **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction) - **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale - **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.) - **pillow-heif** for HEIC/HEIF (iPhone photos) - **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata - **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats - **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files) > **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder. --- ## 3. Data Model (SQLite via SQLAlchemy) ```sql -- Core tables photos ( id TEXT PRIMARY KEY, -- UUID filepath TEXT UNIQUE NOT NULL, filename TEXT NOT NULL, folder_id TEXT REFERENCES folders(id), media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic' original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc. width INTEGER, height INTEGER, file_size INTEGER, taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual' added_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME, is_trashed BOOLEAN DEFAULT 0, trashed_at DATETIME, thumb_small TEXT, -- path to 240px thumb thumb_medium TEXT, -- path to 640px thumb thumb_large TEXT, -- path to 1280px thumb exif_json TEXT, -- full EXIF/XMP blob as JSON user_title TEXT, -- user-edited title user_notes TEXT, rating INTEGER DEFAULT 0, -- 0-5 stars color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL is_picked BOOLEAN DEFAULT 0, is_rejected BOOLEAN DEFAULT 0 ) folders ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT UNIQUE NOT NULL, parent_id TEXT REFERENCES folders(id), source_root_id TEXT REFERENCES source_roots(id), photo_count INTEGER DEFAULT 0, last_scanned DATETIME ) source_roots ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT UNIQUE NOT NULL, is_active BOOLEAN DEFAULT 1, added_at DATETIME DEFAULT CURRENT_TIMESTAMP ) tags ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, color TEXT ) photo_tags ( photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE, tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (photo_id, tag_id) ) heaps ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME ) heap_photos ( heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE, photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, sort_order INTEGER DEFAULT 0, PRIMARY KEY (heap_id, photo_id) ) -- AI-readiness placeholder (no implementation yet) embeddings ( photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE, model TEXT, -- e.g. 'clip-vit-b32' vector BLOB -- raw float32 bytes ) ``` **Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`. --- ## 4. Configuration App is configured via a `photovault.yml` file mounted into the backend container: ```yaml source_roots: - name: "Main Library" path: /photos/main - name: "iPhone Imports" path: /photos/iphone thumbnails: small: 240 # px, longest edge medium: 640 large: 1280 quality: 85 # JPEG quality format: webp # output format for thumbs scanner: watch: true # use watchfiles inotify initial_scan_on_start: true trash: path: /data/trash ``` --- ## 5. Backend API (FastAPI) All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions. ### 5.1 Photos ``` GET /photos List photos (pagination + filters — see §5.5) GET /photos/{id} Get single photo with full EXIF + tags GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx GET /photos/{id}/original Serve original file (download) PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override) DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash) POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' } POST /photos/bulk/move Move files to a target folder_id POST /photos/bulk/copy Copy files to a target folder_id ``` ### 5.2 Folders ``` GET /folders Folder tree (nested, with photo_count) GET /folders/{id}/photos Photos in folder (supports same filters as /photos) POST /folders Create folder (creates directory on disk) PATCH /folders/{id} Rename folder (renames directory on disk) DELETE /folders/{id} Delete folder — requires folder to be empty POST /folders/{id}/scan Trigger manual re-scan of folder ``` ### 5.3 Heaps ``` GET /heaps List all heaps POST /heaps Create heap { name } GET /heaps/{id} Get heap with photos PATCH /heaps/{id} Rename heap DELETE /heaps/{id} Delete heap (does NOT delete photos) POST /heaps/{id}/photos Add photos { photo_ids: [] } DELETE /heaps/{id}/photos Remove photos { photo_ids: [] } POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool } ``` ### 5.4 Tags ``` GET /tags List all tags with usage counts POST /tags Create tag PATCH /tags/{id} Rename / recolor tag DELETE /tags/{id} Delete tag (removes from all photos) GET /tags/{id}/photos Photos with this tag ``` ### 5.5 Filters & Search All list endpoints support these query parameters: ``` q Full-text search (filename, user_title, user_notes, EXIF JSON) date_from ISO8601 datetime date_to ISO8601 datetime folder_id Filter by folder (recursive if include_subfolders=true) tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR) media_type photo|video|raw|heic (comma-separated for multiple) rating_min 0-5 rating_max 0-5 color_label red|orange|yellow|green|blue|purple|none is_picked true|false is_rejected true|false is_trashed true|false (default false) heap_id Filter to photos in a specific heap sort taken_at|added_at|filename|file_size|rating (default taken_at) order asc|desc (default desc) page integer (default 1) per_page integer (default 100, max 500) ``` Full-text search uses SQLite FTS5. Create a virtual FTS table: ```sql CREATE VIRTUAL TABLE photos_fts USING fts5( photo_id UNINDEXED, filename, user_title, user_notes, exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.) ); ``` ### 5.6 Trash ``` GET /trash List trashed photos (same filters) POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder DELETE /trash/empty Permanently delete all trashed photos + files DELETE /trash/{id} Permanently delete single photo + file ``` ### 5.7 Library Stats & Scanning ``` GET /library/stats { total_photos, total_videos, total_size, last_scan } POST /library/scan Trigger full re-scan (Celery task) GET /library/scan/status { status, progress, current_folder, queued, done } ``` ### 5.8 Background Tasks (Celery) - `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions - `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg - `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB - `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes - `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings **Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue. --- ## 6. Frontend Architecture ### 6.1 Layout Three-pane layout (similar to Lightroom Library module): ``` ┌─────────────────────────────────────────────────────────────┐ │ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │ ├──────────┬──────────────────────────────────┬───────────────┤ │ │ │ │ │ LEFT │ MAIN TIMELINE │ RIGHT │ │ SIDEBAR │ (continuous scroll, │ SIDEBAR │ │ │ sticky date headers, │ (metadata │ │ Folder │ virtualized thumbnail │ panel for │ │ tree │ grid) │ selected │ │ │ │ photo) │ │ Heaps │ │ │ │ list │ │ │ │ │ │ │ │ Tags │ │ │ └──────────┴──────────────────────────────────┴───────────────┘ ``` - Left sidebar: resizable, collapsible (shortcut: `Tab`) - Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected - Main area: full virtualized scroll, single scroll region ### 6.2 Views | View | Shortcut | Description | |------|----------|-------------| | Grid (Library) | `G` | Default timeline thumbnail grid | | Loupe (Fullscreen) | `E` | Single photo full-viewport view | | Compare | `C` | Side-by-side compare of 2 selected photos | ### 6.3 Timeline View (Grid) - **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder) - Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered - Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys) - Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars - **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection - On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion ### 6.4 Keyboard Shortcuts (Lightroom-style) #### Navigation (Grid mode) | Key | Action | |-----|--------| | `←` `→` `↑` `↓` | Move cursor one photo in direction | | `Shift+←/→/↑/↓` | Extend selection | | `Cmd/Ctrl+A` | Select all | | `Cmd/Ctrl+Shift+A` | Deselect all | | `Space` | Quick preview (fullscreen loupe, hold) | | `Enter` | Open loupe view | | `Esc` | Deselect / close loupe | | `Home` / `End` | Jump to first / last photo | | `Page Up/Down` | Scroll by screen height | #### Navigation (Loupe mode) | Key | Action | |-----|--------| | `←` `→` | Previous / next photo | | `Esc` | Return to grid | | `Z` | Toggle zoom (fit ↔ 100%) | | `+` / `-` | Zoom in / out | #### Flagging & Rating | Key | Action | |-----|--------| | `P` | Pick (flag) | | `X` | Reject | | `U` | Unflag | | `1–5` | Set star rating | | `0` | Remove star rating | | `6` | Red label | | `7` | Orange label | | `8` | Yellow label | | `9` | Green label | #### Actions | Key | Action | |-----|--------| | `G` | Go to grid view | | `E` | Go to loupe view | | `C` | Compare view (2 selected) | | `Tab` | Toggle left sidebar | | `I` | Toggle right metadata sidebar | | `\` | Toggle filter bar | | `F` | Toggle fullscreen | | `Delete` | Move selected to trash | | `Shift+Delete` | Permanently delete (if in trash view) | | `Cmd/Ctrl+Z` | Undo last action | | `Cmd/Ctrl+Shift+Z` | Redo | | `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) | | `Cmd/Ctrl+X` | Cut selected (for move) | | `Cmd/Ctrl+V` | Paste into current folder | | `T` | Add/remove from active heap | | `Cmd/Ctrl+F` | Focus search bar | | `/` | Focus search bar | | `?` | Show keyboard shortcut reference overlay | All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea. ### 6.5 Bulk Selection - **Click** — select single photo (deselects others) - **Shift+Click** — range select from last selected to clicked - **Cmd/Ctrl+Click** — toggle individual photo in selection - **Cmd/Ctrl+A** — select all visible - A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export - Bulk actions call `POST /api/v1/photos/bulk` ### 6.6 Metadata Sidebar (Right Panel) When a photo is selected, the right sidebar shows: **Section: Preview** - Large thumbnail (clicking opens loupe) - Filename (editable inline, renames file on disk) - User title (editable) - User notes (textarea) - Rating (5-star widget, keyboard-interactive) - Color label (color dot picker) - Flags: Picked / Rejected toggles **Section: Tags** - Tag chips with remove button - "Add tag" autocomplete input - Create new tag inline **Section: EXIF / Metadata** Collapsible groups: - *Camera*: Make, Model, Serial, Lens, Firmware - *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode - *File*: Format, Dimensions, File Size, Color Space, Bit Depth - *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand) - *IPTC/XMP*: Copyright, Creator, Description, Keywords **Section: Histogram** (stretch goal) - Live RGB+Luminosity histogram rendered from a downsampled version of the photo ### 6.7 Filter Bar A collapsible horizontal bar below the top bar (shortcut `\`). Contains: | Control | Type | |---------|------| | Date range | Date range picker (from/to) | | Media type | Multi-select chips: Photo / Video / RAW / HEIC | | Rating | Min/max star slider | | Color label | Color dot multi-select | | Flags | Picked / Rejected / Unflagged toggle buttons | | Tags | Multi-select tag dropdown (AND/OR mode toggle) | | Camera make | Dropdown (populated from DB) | | Lens | Dropdown (populated from DB) | Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks. ### 6.8 Search - Magnifier icon in top bar, shortcut `/` or `Cmd+F` - Full-text search via FTS5 backend - Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags - Results appear inline in the current view (no separate search results page) - Search combined with active filters (additive) ### 6.9 Folder Tree (Left Sidebar) - Hierarchical tree view of all source roots and their subfolder structure - Each folder shows photo count badge - Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path - Drag-and-drop folders to rearrange (moves directory on disk with confirmation) - "All Photos" virtual root node at top - "Trash" virtual node at bottom with count badge ### 6.10 Heaps Panel (Left Sidebar) - List of named heaps below folder tree - "+ New Heap" button (creates unnamed heap, prompts for name) - Each heap shows photo count - Click heap → main area shows heap contents in grid - Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap - **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap. - A persistent "current heap" pill shown in the top bar when a heap is active ### 6.11 Loupe View - Single photo, full-viewport - Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display) - Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+ - Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable - Left panel collapse, right metadata panel still accessible - For videos: HTML5 `