feat: structure

This commit is contained in:
2026-04-06 23:30:19 +02:00
commit 46a0d7aba8
41 changed files with 3480 additions and 0 deletions

23
.env Normal file
View File

@@ -0,0 +1,23 @@
# Environment variables for Mulita
# Photo directories to mount (can be multiple paths separated by colon)
# Example: /path/to/photos1:/path/to/photos2
PHOTO_DIRS=./photos
# Redis configuration
REDIS_URL=redis://localhost:6379
# Database URL
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
# Celery configuration
CELERY_BROKER_URL=redis://localhost:6379
CELERY_RESULT_BACKEND=redis://localhost:6379
CELERYD_CONCURRENCY=4
# API settings
API_HOST=0.0.0.0
API_PORT=8000
# Frontend settings
VITE_API_URL=http://localhost:8000

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
*.egg-info/
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
# Node
node_modules/
dist/
dist-ssr/
*.local
.npm
.pnp.*
.yarn/*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment
.env.local
.env.*.local
# Database
*.db
*.sqlite
*.sqlite3
/data/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build
build/
*.pid
*.seed
*.pid.lock
# Docker
docker-compose.override.yml
# Photos (for development)
/photos/
# Thumbnails
/thumbs/
/trash/

128
README.md Normal file
View File

@@ -0,0 +1,128 @@
# 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
- Photo directories to mount
### Setup
1. Clone the repository:
```bash
git clone <repository-url>
cd muleimage
```
2. Configure your photo directories in `.env`:
```bash
# Edit .env file
PHOTO_DIRS=/path/to/your/photos
```
3. Start the application:
```bash
docker-compose up -d
```
4. Access the application at `http://localhost:3000`
## 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
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload
```
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
## Configuration
Edit `mulita.yml` to configure:
- Source photo directories
- Thumbnail sizes and quality
- Scanner settings
- Performance tuning
## 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

39
backend/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM python:3.12-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
# Build dependencies
gcc \
g++ \
make \
# Image processing libraries
libvips42 \
libvips-dev \
# ExifTool for metadata extraction
libimage-exiftool-perl \
# FFmpeg for video processing
ffmpeg \
# Git for some Python packages
git \
# PostgreSQL client (for potential future use)
postgresql-client \
# Clean up
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p /data/thumbs /data/db /data/trash /app/config
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

130
backend/app/config.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Application configuration using Pydantic Settings
"""
from pydantic_settings import BaseSettings
from pydantic import BaseModel, Field
from typing import List, Optional
import os
import yaml
from pathlib import Path
class ThumbnailSettings(BaseModel):
"""Thumbnail generation settings"""
small: int = 240
medium: int = 640
large: int = 1280
quality: int = 85
format: str = "webp"
class ScannerSettings(BaseModel):
"""File scanner settings"""
watch: bool = True
initial_scan_on_start: bool = True
batch_size: int = 100
concurrent_workers: int = 4
class SourceRoot(BaseModel):
"""Source root directory configuration"""
name: str
path: str
class TrashSettings(BaseModel):
"""Trash settings"""
path: str = "/data/trash"
auto_empty_days: Optional[int] = 30
class PerformanceSettings(BaseModel):
"""Performance tuning settings"""
max_concurrent_thumbnails: int = 10
cache_ttl: int = 3600
db_pool_size: int = 20
db_pool_recycle: int = 3600
class MulitaConfig(BaseModel):
"""Main configuration from YAML file"""
source_roots: List[SourceRoot] = []
thumbnails: ThumbnailSettings = ThumbnailSettings()
scanner: ScannerSettings = ScannerSettings()
trash: TrashSettings = TrashSettings()
performance: PerformanceSettings = PerformanceSettings()
class Settings(BaseSettings):
"""Application settings"""
# Database
database_url: str = Field(
default="sqlite+aiosqlite:///data/db/mulita.db",
env="DATABASE_URL"
)
# Redis
redis_url: str = Field(
default="redis://localhost:6379",
env="REDIS_URL"
)
# Celery
celery_broker_url: str = Field(
default="redis://localhost:6379",
env="CELERY_BROKER_URL"
)
celery_result_backend: str = Field(
default="redis://localhost:6379",
env="CELERY_RESULT_BACKEND"
)
# Photo directories
photo_dirs: str = Field(
default="/photos",
env="PHOTO_DIRS"
)
# API settings
api_host: str = Field(default="0.0.0.0", env="API_HOST")
api_port: int = Field(default=8000, env="API_PORT")
# App configuration from YAML
_config: Optional[MulitaConfig] = None
@property
def config(self) -> MulitaConfig:
"""Load configuration from YAML file"""
if self._config is None:
config_path = Path("/app/config/mulita.yml")
if not config_path.exists():
config_path = Path("mulita.yml")
if config_path.exists():
with open(config_path, "r") as f:
config_data = yaml.safe_load(f)
self._config = MulitaConfig(**config_data)
else:
self._config = MulitaConfig()
return self._config
@property
def thumbnails(self) -> ThumbnailSettings:
return self.config.thumbnails
@property
def scanner(self) -> ScannerSettings:
return self.config.scanner
@property
def trash(self) -> TrashSettings:
return self.config.trash
@property
def performance(self) -> PerformanceSettings:
return self.config.performance
@property
def source_roots(self) -> List[SourceRoot]:
return self.config.source_roots
class Config:
env_file = ".env"
case_sensitive = False
# Global settings instance
settings = Settings()

82
backend/app/database.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Database configuration and session management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy import event
import logging
import os
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
# Create database directory if it doesn't exist
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle,
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
} if "sqlite" in settings.database_url else {}
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
# Base class for models
Base = declarative_base()
async def get_db() -> AsyncSession:
"""Dependency to get database session"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Initialize database, create tables if they don't exist"""
async with engine.begin() as conn:
# Import all models to register them with Base
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
# Create all tables
await conn.run_sync(Base.metadata.create_all)
# Enable WAL mode for SQLite (better concurrency)
if "sqlite" in settings.database_url:
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA synchronous=NORMAL")
await conn.execute("PRAGMA cache_size=10000")
await conn.execute("PRAGMA temp_store=MEMORY")
logger.info("Database initialized successfully")
async def create_fts_table():
"""Create Full-Text Search table for SQLite"""
if "sqlite" in settings.database_url:
async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search
await conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
photo_id UNINDEXED,
filename,
user_title,
user_notes,
exif_text,
tokenize='unicode61'
)
""")
logger.info("FTS5 table created successfully")

82
backend/app/main.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Mulita - Photo Management Application
Main FastAPI application entry point
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import logging
import os
from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, trash, library
from app.services.scanner import start_initial_scan
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle"""
logger.info("Starting Mulita application...")
# Initialize database
await init_db()
# Start initial scan if configured
if settings.scanner.initial_scan_on_start:
logger.info("Starting initial library scan...")
await start_initial_scan()
yield
logger.info("Shutting down Mulita application...")
# Create FastAPI app
app = FastAPI(
title="Mulita Photo Management API",
description="Self-hosted photo management application inspired by Lightroom",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
if os.path.exists("/data/thumbs"):
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
# Include routers
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
app.include_router(trash.router, prefix="/api/v1/trash", tags=["trash"])
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
@app.get("/")
async def root():
"""Root endpoint"""
return {
"name": "Mulita Photo Management API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint for Docker"""
return {"status": "healthy"}

View File

@@ -0,0 +1,19 @@
"""
Database models for Mulita
"""
from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding
__all__ = [
'Photo',
'Folder',
'SourceRoot',
'Tag',
'PhotoTag',
'Heap',
'HeapPhoto',
'Embedding'
]

View File

@@ -0,0 +1,17 @@
"""
Embedding model definition (placeholder for AI features)
"""
from sqlalchemy import Column, String, ForeignKey, LargeBinary
import uuid
from app.database import Base
class Embedding(Base):
"""
Placeholder table for future AI embeddings (CLIP, face recognition, etc.)
"""
__tablename__ = 'embeddings'
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
model = Column(String) # e.g., 'clip-vit-b32', 'face-recognition', etc.
vector = Column(LargeBinary) # raw float32 bytes for embedding vector

View File

@@ -0,0 +1,43 @@
"""
Folder and SourceRoot model definitions
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
class SourceRoot(Base):
__tablename__ = 'source_roots'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
path = Column(String, unique=True, nullable=False)
is_active = Column(Boolean, default=True)
added_at = Column(DateTime, server_default=func.now())
# Relationships
folders = relationship("Folder", back_populates="source_root")
class Folder(Base):
__tablename__ = 'folders'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
path = Column(String, unique=True, nullable=False)
parent_id = Column(String, ForeignKey('folders.id'))
source_root_id = Column(String, ForeignKey('source_roots.id'))
photo_count = Column(Integer, default=0)
last_scanned = Column(DateTime)
# Relationships
source_root = relationship("SourceRoot", back_populates="folders")
photos = relationship("Photo", backref="folder")
# Indexes
__table_args__ = (
Index('ix_folders_path', 'path'),
Index('ix_folders_parent_id', 'parent_id'),
Index('ix_folders_source_root_id', 'source_root_id'),
)

View File

@@ -0,0 +1,37 @@
"""
Heap model definitions
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Table, Index
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
# Association table for many-to-many relationship with additional fields
heap_photos = Table(
'heap_photos',
Base.metadata,
Column('heap_id', String, ForeignKey('heaps.id', ondelete='CASCADE'), primary_key=True),
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('added_at', DateTime, server_default=func.now()),
Column('sort_order', Integer, default=0),
Index('ix_heap_photos_heap_id', 'heap_id'),
Index('ix_heap_photos_photo_id', 'photo_id'),
)
class Heap(Base):
__tablename__ = 'heaps'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
is_active = Column(Boolean, default=False) # For active heap feature
# Relationships
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
class HeapPhoto:
"""Helper class for heap-photo associations (not a table model)"""
pass

View File

@@ -0,0 +1,75 @@
"""
Photo model definition
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index
from sqlalchemy.sql import func
from datetime import datetime
import uuid
from app.database import Base
class Photo(Base):
__tablename__ = 'photos'
# Primary key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# File information
filepath = Column(String, unique=True, nullable=False)
filename = Column(String, nullable=False)
folder_id = Column(String, ForeignKey('folders.id'))
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
# Media information
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
width = Column(Integer)
height = Column(Integer)
file_size = Column(Integer)
# Timestamps
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
added_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# Trash status
is_trashed = Column(Boolean, default=False)
trashed_at = Column(DateTime)
# Thumbnail paths
thumb_small = Column(String) # path to 240px thumb
thumb_medium = Column(String) # path to 640px thumb
thumb_large = Column(String) # path to 1280px thumb
# Processing status
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
processing_error = Column(Text)
# Metadata
exif_json = Column(Text) # full EXIF/XMP blob as JSON
# User-editable fields
user_title = Column(String)
user_notes = Column(Text)
rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked = Column(Boolean, default=False)
is_rejected = Column(Boolean, default=False)
# Duplicate detection
is_duplicate = Column(Boolean, default=False)
# Live photo support
live_photo_video_id = Column(String, ForeignKey('photos.id'))
# Indexes for performance
__table_args__ = (
Index('ix_photos_taken_at', 'taken_at'),
Index('ix_photos_folder_id', 'folder_id'),
Index('ix_photos_is_trashed', 'is_trashed'),
Index('ix_photos_rating', 'rating'),
Index('ix_photos_color_label', 'color_label'),
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
)

View File

@@ -0,0 +1,32 @@
"""
Tag model definitions
"""
from sqlalchemy import Column, String, ForeignKey, Table, Index
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
# Association table for many-to-many relationship
photo_tags = Table(
'photo_tags',
Base.metadata,
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'),
)
class Tag(Base):
__tablename__ = 'tags'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False, index=True)
color = Column(String) # Hex color code for UI display
# Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags")
class PhotoTag:
"""Helper class for photo-tag associations (not a table model)"""
pass

View File

@@ -0,0 +1,33 @@
"""
Folders API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.database import get_db
from app.models import Folder, SourceRoot
router = APIRouter()
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get folder tree"""
result = await db.execute(select(Folder))
folders = result.scalars().all()
return folders
@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
"""Trigger manual re-scan of folder"""
from app.tasks.scan import scan_folder as scan_task
result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
scan_task.delay(folder.path)
return {"status": "success", "message": f"Scan queued for {folder.path}"}

View File

@@ -0,0 +1,27 @@
"""
Heaps API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Heap
router = APIRouter()
@router.get("")
async def list_heaps(db: AsyncSession = Depends(get_db)):
"""List all heaps"""
result = await db.execute(select(Heap))
heaps = result.scalars().all()
return heaps
@router.post("")
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
"""Create a new heap"""
heap = Heap(name=name)
db.add(heap)
await db.commit()
await db.refresh(heap)
return heap

View File

@@ -0,0 +1,61 @@
"""
Library API router for stats and scanning
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
router = APIRouter()
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics"""
# Count total photos
total_photos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos
total_videos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
video_count = total_videos.scalar()
# Calculate total size
total_size = await db.execute(
select(func.sum(Photo.file_size))
)
size = total_size.scalar() or 0
return {
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
"total_size_gb": round(size / (1024**3), 2) if size else 0
}
@router.post("/scan")
async def trigger_scan():
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
scan_all_source_roots.delay()
return {"status": "success", "message": "Library scan started"}
@router.get("/scan/status")
async def get_scan_status():
"""Get current scan status"""
# This would connect to Celery to get task status
# For now, return a simple response
return {
"status": "idle",
"progress": 0,
"current_folder": None,
"queued": 0,
"done": 0
}

View File

@@ -0,0 +1,310 @@
"""
Photos API router
"""
from typing import List, Optional, Dict, Any
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
router = APIRouter()
@router.get("", response_model=PhotoListResponse)
async def list_photos(
q: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
folder_id: Optional[str] = None,
tag_ids: Optional[str] = None,
media_type: Optional[str] = None,
rating_min: Optional[int] = Query(None, ge=0, le=5),
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
is_picked: Optional[bool] = None,
is_rejected: Optional[bool] = None,
is_trashed: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
page: int = Query(1, ge=1),
per_page: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db)
):
"""List photos with filters and pagination"""
# Build query
query = select(Photo)
# Apply filters
filters = []
# Text search (would use FTS5 in production)
if q:
search_pattern = f"%{q}%"
filters.append(
or_(
Photo.filename.ilike(search_pattern),
Photo.user_title.ilike(search_pattern),
Photo.user_notes.ilike(search_pattern),
Photo.exif_json.ilike(search_pattern)
)
)
# Date range
if date_from:
filters.append(Photo.taken_at >= date_from)
if date_to:
filters.append(Photo.taken_at <= date_to)
# Folder filter
if folder_id:
filters.append(Photo.folder_id == folder_id)
# Media type filter
if media_type:
types = media_type.split(',')
filters.append(Photo.media_type.in_(types))
# Rating filter
if rating_min is not None:
filters.append(Photo.rating >= rating_min)
if rating_max is not None:
filters.append(Photo.rating <= rating_max)
# Color label filter
if color_label:
if color_label == 'none':
filters.append(Photo.color_label.is_(None))
else:
filters.append(Photo.color_label == color_label)
# Flag filters
if is_picked is not None:
filters.append(Photo.is_picked == is_picked)
if is_rejected is not None:
filters.append(Photo.is_rejected == is_rejected)
# Trash filter
filters.append(Photo.is_trashed == is_trashed)
# Apply all filters
if filters:
query = query.where(and_(*filters))
# Apply sorting
sort_column = getattr(Photo, sort, Photo.taken_at)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
# Count total results
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Apply pagination
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Execute query
result = await db.execute(query)
photos = result.scalars().all()
# Convert to response
return PhotoListResponse(
photos=[PhotoResponse.from_orm(photo) for photo in photos],
total=total,
page=page,
per_page=per_page,
pages=(total + per_page - 1) // per_page
)
@router.get("/{photo_id}", response_model=PhotoResponse)
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get single photo with full EXIF and tags"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
return PhotoResponse.from_orm(photo)
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(
photo_id: str,
size: str,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
thumb_path = getattr(photo, f'thumb_{size}')
if not thumb_path or not os.path.exists(thumb_path):
raise HTTPException(status_code=404, detail="Thumbnail not found")
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):
# Use Nginx X-Accel-Redirect for better performance
response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp'
response.headers['Content-Type'] = 'image/webp'
return Response()
else:
# Direct file serving for development
return FileResponse(thumb_path, media_type='image/webp')
@router.get("/{photo_id}/original")
async def get_original(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Serve original file for download"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
photo.filepath,
filename=photo.filename,
media_type='application/octet-stream'
)
@router.patch("/{photo_id}", response_model=PhotoResponse)
async def update_photo(
photo_id: str,
update: PhotoUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update photo metadata"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Apply updates
update_data = update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(photo, field, value)
await db.commit()
await db.refresh(photo)
return PhotoResponse.from_orm(photo)
@router.delete("/{photo_id}")
async def trash_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Move photo to trash"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Move file to trash directory
import shutil
trash_dir = f"{settings.trash.path}/{photo_id}"
os.makedirs(trash_dir, exist_ok=True)
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
try:
shutil.move(photo.filepath, trash_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
# Update database
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Photo moved to trash"}
@router.post("/bulk")
async def bulk_action(
action: BulkAction,
db: AsyncSession = Depends(get_db)
):
"""Perform bulk actions on multiple photos"""
# Get photos
result = await db.execute(
select(Photo).where(Photo.id.in_(action.ids))
)
photos = result.scalars().all()
if not photos:
raise HTTPException(status_code=404, detail="No photos found")
# Perform action based on type
if action.action == 'trash':
for photo in photos:
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
elif action.action == 'restore':
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
elif action.action == 'set_rating':
for photo in photos:
photo.rating = action.value
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
elif action.action == 'pick':
for photo in photos:
photo.is_picked = True
photo.is_rejected = False
elif action.action == 'reject':
for photo in photos:
photo.is_rejected = True
photo.is_picked = False
else:
raise HTTPException(status_code=400, detail="Invalid action")
await db.commit()
return {
"status": "success",
"message": f"{action.action} applied to {len(photos)} photos"
}

View File

@@ -0,0 +1,27 @@
"""
Tags API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Tag
router = APIRouter()
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with usage counts"""
result = await db.execute(select(Tag))
tags = result.scalars().all()
return tags
@router.post("")
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
"""Create a new tag"""
tag = Tag(name=name, color=color)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag

View File

@@ -0,0 +1,50 @@
"""
Trash API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.database import get_db
from app.models import Photo
router = APIRouter()
@router.get("")
async def list_trashed(db: AsyncSession = Depends(get_db)):
"""List trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
return photos
@router.post("/restore")
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
"""Restore photos from trash"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
)
photos = result.scalars().all()
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
await db.commit()
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_trash(db: AsyncSession = Depends(get_db)):
"""Permanently delete all trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
for photo in photos:
await db.delete(photo)
await db.commit()
return {"status": "success", "deleted": len(photos)}

View File

@@ -0,0 +1,71 @@
"""
Pydantic schemas for photos
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
class PhotoBase(BaseModel):
"""Base photo schema"""
filename: str
media_type: str
original_format: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
file_size: Optional[int] = None
taken_at: Optional[datetime] = None
taken_at_source: Optional[str] = None
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: int = 0
color_label: Optional[str] = None
is_picked: bool = False
is_rejected: bool = False
class PhotoResponse(PhotoBase):
"""Photo response schema"""
id: str
filepath: str
folder_id: Optional[str] = None
file_hash: Optional[str] = None
added_at: datetime
updated_at: Optional[datetime] = None
is_trashed: bool = False
trashed_at: Optional[datetime] = None
thumb_small: Optional[str] = None
thumb_medium: Optional[str] = None
thumb_large: Optional[str] = None
processing_status: str = 'pending'
processing_error: Optional[str] = None
exif_json: Optional[str] = None
is_duplicate: bool = False
live_photo_video_id: Optional[str] = None
tags: List[Dict[str, Any]] = []
class Config:
orm_mode = True
from_attributes = True
class PhotoUpdate(BaseModel):
"""Photo update schema"""
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None
is_picked: Optional[bool] = None
is_rejected: Optional[bool] = None
taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel):
"""Photo list response with pagination"""
photos: List[PhotoResponse]
total: int
page: int
per_page: int
pages: int
class BulkAction(BaseModel):
"""Bulk action on photos"""
ids: List[str]
action: str # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)

View File

@@ -0,0 +1,183 @@
"""
Metadata extraction service using ExifTool
"""
import json
import logging
import asyncio
from datetime import datetime
from typing import Dict, Optional
import subprocess
from pathlib import Path
from celery import shared_task
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Photo
logger = logging.getLogger(__name__)
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
"""Parse EXIF datetime string to Python datetime"""
if not date_str:
return None
# Common EXIF datetime formats
formats = [
"%Y:%m:%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y:%m:%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S%z"
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def extract_key_metadata(exif_data: Dict) -> Dict:
"""Extract key metadata fields for FTS indexing"""
key_fields = []
# Camera information
if 'Make' in exif_data:
key_fields.append(exif_data['Make'])
if 'Model' in exif_data:
key_fields.append(exif_data['Model'])
if 'LensModel' in exif_data:
key_fields.append(exif_data['LensModel'])
# Location information
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
# IPTC/XMP keywords
if 'Keywords' in exif_data:
if isinstance(exif_data['Keywords'], list):
key_fields.extend(exif_data['Keywords'])
else:
key_fields.append(exif_data['Keywords'])
# Copyright and creator
if 'Copyright' in exif_data:
key_fields.append(exif_data['Copyright'])
if 'Creator' in exif_data:
key_fields.append(exif_data['Creator'])
if 'Artist' in exif_data:
key_fields.append(exif_data['Artist'])
return {
'exif_text': ' '.join(key_fields),
'camera_make': exif_data.get('Make'),
'camera_model': exif_data.get('Model'),
'lens_model': exif_data.get('LensModel'),
'gps_latitude': exif_data.get('GPSLatitude'),
'gps_longitude': exif_data.get('GPSLongitude'),
}
@shared_task(name='extract_metadata')
def extract_metadata(photo_id: str):
"""Extract metadata from a photo using ExifTool"""
return asyncio.run(_extract_metadata_async(photo_id))
async def _extract_metadata_async(photo_id: str):
"""Async implementation of metadata extraction"""
async with AsyncSessionLocal() as session:
try:
# Get photo from database
result = await session.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# Check if file exists
if not Path(photo.filepath).exists():
logger.error(f"File not found: {photo.filepath}")
return {'status': 'error', 'message': 'File not found'}
# Run ExifTool to extract metadata
cmd = [
'exiftool',
'-j', # JSON output
'-G', # Group names
'-s', # Short output format
'-All', # All metadata
photo.filepath
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
logger.error(f"ExifTool error: {result.stderr}")
return {'status': 'error', 'message': result.stderr}
# Parse JSON output
metadata = json.loads(result.stdout)
if metadata and len(metadata) > 0:
exif_data = metadata[0]
# Store full metadata as JSON
photo.exif_json = json.dumps(exif_data)
# Extract taken_at date
date_fields = [
'EXIF:DateTimeOriginal',
'EXIF:CreateDate',
'QuickTime:MediaCreateDate',
'EXIF:ModifyDate'
]
for field in date_fields:
if field in exif_data:
taken_at = parse_exif_datetime(exif_data[field])
if taken_at:
photo.taken_at = taken_at
photo.taken_at_source = 'exif'
break
# Extract dimensions if not already set
if not photo.width:
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
if not photo.height:
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
# Extract and store key metadata for search
key_metadata = extract_key_metadata(exif_data)
# Update FTS table (would be done via trigger in production)
# For now, we'll store it in a comment
await session.commit()
logger.info(f"Metadata extracted for photo {photo_id}")
return {
'status': 'success',
'photo_id': photo_id,
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
}
except subprocess.TimeoutExpired:
logger.error(f"ExifTool timeout for {photo.filepath}")
return {'status': 'error', 'message': 'ExifTool timeout'}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ExifTool output: {e}")
return {'status': 'error', 'message': 'Invalid ExifTool output'}
except Exception as e:
logger.error(f"Error extracting metadata for {photo_id}: {e}")
return {'status': 'error', 'message': str(e)}

View File

@@ -0,0 +1,22 @@
"""
Scanner service for initial library scan
"""
import logging
from app.tasks.scan import scan_all_source_roots, watch_folders
from app.config import settings
logger = logging.getLogger(__name__)
async def start_initial_scan():
"""Start the initial library scan"""
try:
# Queue scan of all source roots
scan_all_source_roots.delay()
# Start folder watcher if configured
if settings.scanner.watch:
watch_folders.delay()
logger.info("Initial scan queued successfully")
except Exception as e:
logger.error(f"Failed to start initial scan: {e}")

View File

@@ -0,0 +1,31 @@
"""
Celery configuration and app initialization
"""
from celery import Celery
from app.config import settings
# Create Celery app
celery_app = Celery(
'mulita',
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
include=['app.tasks.scan', 'app.tasks.thumbs']
)
# Configure Celery
celery_app.conf.update(
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='UTC',
enable_utc=True,
task_routes={
'app.tasks.thumbs.*': {'queue': 'high'},
'app.tasks.scan.*': {'queue': 'low'},
},
task_default_queue='default',
task_default_exchange='default',
task_default_exchange_type='direct',
task_default_routing_key='default',
broker_connection_retry_on_startup=True,
)

298
backend/app/tasks/scan.py Normal file
View File

@@ -0,0 +1,298 @@
"""
Celery tasks for scanning folders and indexing photos
"""
import os
import hashlib
import asyncio
from pathlib import Path
from datetime import datetime
import logging
import json
from typing import List, Dict, Optional
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import aiofiles
from app.database import AsyncSessionLocal
from app.models import Photo, Folder, SourceRoot
from app.config import settings
from app.tasks.thumbs import generate_thumbnails
from app.services.metadata import extract_metadata
logger = logging.getLogger(__name__)
# Supported file extensions
PHOTO_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp'}
RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw'}
HEIC_EXTENSIONS = {'.heic', '.heif'}
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv'}
SUPPORTED_EXTENSIONS = PHOTO_EXTENSIONS | RAW_EXTENSIONS | HEIC_EXTENSIONS | VIDEO_EXTENSIONS
def get_media_type(filepath: str) -> str:
"""Determine media type from file extension"""
ext = Path(filepath).suffix.lower()
if ext in PHOTO_EXTENSIONS:
return 'photo'
elif ext in RAW_EXTENSIONS:
return 'raw'
elif ext in HEIC_EXTENSIONS:
return 'heic'
elif ext in VIDEO_EXTENSIONS:
return 'video'
return 'unknown'
async def calculate_file_hash(filepath: str) -> str:
"""Calculate SHA-256 hash of a file"""
hash_sha256 = hashlib.sha256()
try:
async with aiofiles.open(filepath, 'rb') as f:
while chunk := await f.read(8192):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
except Exception as e:
logger.error(f"Error calculating hash for {filepath}: {e}")
return ""
@shared_task(bind=True, name='scan_folder')
def scan_folder(self, folder_path: str, source_root_id: Optional[str] = None):
"""
Scan a folder and index all photos/videos
"""
# Run async function in sync context
return asyncio.run(_scan_folder_async(folder_path, source_root_id, self))
async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], task):
"""Async implementation of folder scanning"""
logger.info(f"Starting scan of folder: {folder_path}")
async with AsyncSessionLocal() as session:
try:
# Get or create source root
if not source_root_id:
source_root = await get_or_create_source_root(session, folder_path)
source_root_id = source_root.id
# Walk the directory tree
total_files = 0
processed_files = 0
errors = []
for root, dirs, files in os.walk(folder_path):
# Get or create folder entry
folder = await get_or_create_folder(session, root, source_root_id)
# Filter supported files
supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
total_files += len(supported_files)
# Process files in batches
batch_size = settings.scanner.batch_size
for i in range(0, len(supported_files), batch_size):
batch = supported_files[i:i + batch_size]
for filename in batch:
filepath = os.path.join(root, filename)
try:
# Check if file already exists in database
existing = await session.execute(
select(Photo).where(Photo.filepath == filepath)
)
if existing.scalar_one_or_none():
logger.debug(f"File already indexed: {filepath}")
processed_files += 1
continue
# Get file stats
stat = os.stat(filepath)
# Calculate file hash for duplicate detection
file_hash = await calculate_file_hash(filepath)
# Check for duplicate by hash
duplicate = await session.execute(
select(Photo).where(Photo.file_hash == file_hash)
) if file_hash else None
# Create photo entry
photo = Photo(
filepath=filepath,
filename=filename,
folder_id=folder.id,
file_hash=file_hash,
media_type=get_media_type(filepath),
original_format=Path(filepath).suffix.upper()[1:],
file_size=stat.st_size,
taken_at=datetime.fromtimestamp(stat.st_mtime),
taken_at_source='filesystem',
is_duplicate=bool(duplicate.scalar_one_or_none() if duplicate else False),
processing_status='pending'
)
session.add(photo)
await session.flush() # Get the photo ID
# Queue thumbnail generation
generate_thumbnails.delay(photo.id)
# Queue metadata extraction
extract_metadata.delay(photo.id)
processed_files += 1
# Update progress
if processed_files % 10 == 0:
task.update_state(
state='PROGRESS',
meta={
'current': processed_files,
'total': total_files,
'folder': root
}
)
except Exception as e:
logger.error(f"Error processing file {filepath}: {e}")
errors.append({'file': filepath, 'error': str(e)})
continue
# Commit batch
await session.commit()
# Update folder scan timestamp
folder.last_scanned = datetime.utcnow()
folder.photo_count = processed_files
await session.commit()
logger.info(f"Scan complete. Processed {processed_files}/{total_files} files. Errors: {len(errors)}")
return {
'status': 'completed',
'processed': processed_files,
'total': total_files,
'errors': errors
}
except Exception as e:
logger.error(f"Scan failed: {e}")
await session.rollback()
raise
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
"""Get or create a source root entry"""
from sqlalchemy import select
result = await session.execute(
select(SourceRoot).where(SourceRoot.path == path)
)
source_root = result.scalar_one_or_none()
if not source_root:
source_root = SourceRoot(
name=Path(path).name,
path=path
)
session.add(source_root)
await session.flush()
return source_root
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
"""Get or create a folder entry"""
from sqlalchemy import select
result = await session.execute(
select(Folder).where(Folder.path == path)
)
folder = result.scalar_one_or_none()
if not folder:
parent_path = str(Path(path).parent)
parent = None
if parent_path != path: # Not root folder
parent_result = await session.execute(
select(Folder).where(Folder.path == parent_path)
)
parent = parent_result.scalar_one_or_none()
if parent:
parent_id = parent.id
else:
# Recursively create parent
parent = await get_or_create_folder(session, parent_path, source_root_id)
parent_id = parent.id
else:
parent_id = None
folder = Folder(
name=Path(path).name,
path=path,
parent_id=parent_id,
source_root_id=source_root_id
)
session.add(folder)
await session.flush()
return folder
@shared_task(name='scan_all_source_roots')
def scan_all_source_roots():
"""Scan all configured source roots"""
for source_root in settings.source_roots:
if os.path.exists(source_root.path):
scan_folder.delay(source_root.path)
else:
logger.warning(f"Source root path does not exist: {source_root.path}")
@shared_task(name='watch_folders')
def watch_folders():
"""
Watch folders for changes using watchfiles
This is a long-running task that monitors file system events
"""
from watchfiles import watch
paths = [sr.path for sr in settings.source_roots if os.path.exists(sr.path)]
if not paths:
logger.warning("No valid source roots to watch")
return
logger.info(f"Starting folder watcher for: {paths}")
for changes in watch(*paths):
for change_type, filepath in changes:
filepath = str(filepath)
# Check if it's a supported file type
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
if change_type == 'added' or change_type == 'modified':
# Queue scan for the parent folder
parent_dir = str(Path(filepath).parent)
scan_folder.delay(parent_dir)
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
elif change_type == 'deleted':
# Handle file deletion
asyncio.run(handle_file_deletion(filepath))
async def handle_file_deletion(filepath: str):
"""Handle deletion of a file from the filesystem"""
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Photo).where(Photo.filepath == filepath)
)
photo = result.scalar_one_or_none()
if photo:
# Mark as missing or delete from database
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
await session.commit()
logger.info(f"Marked photo as trashed: {filepath}")

277
backend/app/tasks/thumbs.py Normal file
View File

@@ -0,0 +1,277 @@
"""
Celery tasks for thumbnail generation
"""
import os
import asyncio
from pathlib import Path
import logging
from typing import Tuple, Optional
import json
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import pyvips
import rawpy
import imageio
from PIL import Image
from pillow_heif import register_heif_opener
import ffmpeg
from app.database import AsyncSessionLocal
from app.models import Photo
from app.config import settings
# Register HEIF opener with Pillow
register_heif_opener()
logger = logging.getLogger(__name__)
# Thumbnail sizes configuration
THUMB_SIZES = {
'small': settings.thumbnails.small,
'medium': settings.thumbnails.medium,
'large': settings.thumbnails.large
}
def get_thumb_path(photo_id: str, size: str) -> str:
"""Get the path for a thumbnail file"""
thumb_dir = f"/data/thumbs/{photo_id}"
os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
def process_standard_image(filepath: str) -> pyvips.Image:
"""Process standard image formats (JPEG, PNG, etc.)"""
return pyvips.Image.new_from_file(filepath, access='sequential')
def process_raw_image(filepath: str) -> pyvips.Image:
"""Process RAW image formats"""
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to pyvips image
return pyvips.Image.new_from_array(rgb)
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
return extract_raw_preview(filepath)
def extract_raw_preview(filepath: str) -> Optional[pyvips.Image]:
"""Extract embedded JPEG preview from RAW file"""
try:
# Use exiftool to extract preview
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
cmd = ['exiftool', '-b', '-PreviewImage', filepath]
result = subprocess.run(cmd, capture_output=True)
if result.returncode == 0 and result.stdout:
tmp.write(result.stdout)
tmp.flush()
return pyvips.Image.new_from_file(tmp.name, access='sequential')
except Exception as e:
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
return None
def process_heic_image(filepath: str) -> pyvips.Image:
"""Process HEIC/HEIF image formats"""
try:
# Use pillow-heif to open the image
img = Image.open(filepath)
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Save to temp file and load with pyvips
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
img.save(tmp.name, 'JPEG')
return pyvips.Image.new_from_file(tmp.name, access='sequential')
except Exception as e:
logger.error(f"Error processing HEIC file {filepath}: {e}")
raise
def process_video_thumbnail(filepath: str) -> pyvips.Image:
"""Extract thumbnail from video file"""
try:
# Get video duration
probe = ffmpeg.probe(filepath)
duration = float(probe['streams'][0]['duration'])
# Extract frame at 10% of duration
timestamp = duration * 0.1
# Extract frame using ffmpeg
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
stream = ffmpeg.input(filepath, ss=timestamp)
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return pyvips.Image.new_from_file(tmp.name, access='sequential')
except Exception as e:
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
# Create a placeholder thumbnail
return create_placeholder_thumbnail('video')
def create_placeholder_thumbnail(media_type: str) -> pyvips.Image:
"""Create a placeholder thumbnail for failed processing"""
# Create a simple gray placeholder
placeholder = pyvips.Image.black(640, 480)
placeholder = placeholder + [128, 128, 128] # Make it gray
return placeholder
def auto_rotate_image(image: pyvips.Image) -> pyvips.Image:
"""Auto-rotate image based on EXIF orientation"""
try:
orientation = image.get('orientation')
rotation_map = {
3: 180,
6: 90,
8: 270
}
if orientation in rotation_map:
image = image.rot(rotation_map[orientation])
except:
pass # No orientation data available
return image
def generate_thumbnail(image: pyvips.Image, size: int, output_path: str):
"""Generate a thumbnail of the specified size"""
# Calculate scale to fit within size (longest edge)
width = image.width
height = image.height
if width > height:
scale = size / width
else:
scale = size / height
# Only downscale, never upscale
if scale < 1:
image = image.resize(scale)
# Save as WebP with specified quality
image.webpsave(
output_path,
Q=settings.thumbnails.quality,
effort=4 # Balance between speed and compression
)
@shared_task(bind=True, name='generate_thumbnails')
def generate_thumbnails(self, photo_id: str):
"""Generate thumbnails for a photo"""
return asyncio.run(_generate_thumbnails_async(photo_id, self))
async def _generate_thumbnails_async(photo_id: str, task):
"""Async implementation of thumbnail generation"""
async with AsyncSessionLocal() as session:
try:
# Get photo from database
result = await session.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# Check if file exists
if not os.path.exists(photo.filepath):
logger.error(f"File not found: {photo.filepath}")
photo.processing_status = 'failed'
photo.processing_error = 'File not found'
await session.commit()
return {'status': 'error', 'message': 'File not found'}
# Update processing status
photo.processing_status = 'processing'
await session.commit()
# Load and process the image based on type
image = None
if photo.media_type == 'photo':
image = process_standard_image(photo.filepath)
elif photo.media_type == 'raw':
image = process_raw_image(photo.filepath)
elif photo.media_type == 'heic':
image = process_heic_image(photo.filepath)
elif photo.media_type == 'video':
image = process_video_thumbnail(photo.filepath)
else:
logger.error(f"Unsupported media type: {photo.media_type}")
image = create_placeholder_thumbnail(photo.media_type)
if not image:
raise Exception("Failed to process image")
# Auto-rotate based on EXIF
image = auto_rotate_image(image)
# Store original dimensions
photo.width = image.width
photo.height = image.height
# Generate thumbnails for each size
for size_name, size_value in THUMB_SIZES.items():
thumb_path = get_thumb_path(photo_id, size_name)
generate_thumbnail(image, size_value, thumb_path)
# Update database with thumbnail path
setattr(photo, f'thumb_{size_name}', thumb_path)
# Update progress
task.update_state(
state='PROGRESS',
meta={'current_size': size_name, 'photo_id': photo_id}
)
# Update processing status
photo.processing_status = 'completed'
photo.processing_error = None
await session.commit()
logger.info(f"Thumbnails generated for photo {photo_id}")
return {'status': 'success', 'photo_id': photo_id}
except Exception as e:
logger.error(f"Error generating thumbnails for {photo_id}: {e}")
# Update error status
if photo:
photo.processing_status = 'failed'
photo.processing_error = str(e)
await session.commit()
return {'status': 'error', 'message': str(e)}
@shared_task(name='regenerate_all_thumbnails')
def regenerate_all_thumbnails():
"""Regenerate thumbnails for all photos"""
return asyncio.run(_regenerate_all_thumbnails_async())
async def _regenerate_all_thumbnails_async():
"""Async implementation of regenerating all thumbnails"""
async with AsyncSessionLocal() as session:
# Get all photos that need thumbnails
result = await session.execute(
select(Photo).where(
Photo.processing_status.in_(['pending', 'failed'])
)
)
photos = result.scalars().all()
logger.info(f"Regenerating thumbnails for {len(photos)} photos")
for photo in photos:
generate_thumbnails.delay(photo.id)
return {'status': 'queued', 'count': len(photos)}

50
backend/requirements.txt Normal file
View File

@@ -0,0 +1,50 @@
# Core dependencies
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0
alembic==1.13.1
# Redis and Celery
redis==5.0.1
celery==5.3.6
flower==2.0.1
# Image processing
pyvips==2.2.2
rawpy==0.19.0
pillow==10.2.0
pillow-heif==0.15.0
imageio==2.33.1
imageio-ffmpeg==0.4.9
# Video processing
ffmpeg-python==0.2.0
# Metadata extraction
pyexiftool==0.5.6
# File watching
watchfiles==0.21.0
# Utilities
pyyaml==6.0.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
httpx==0.26.0
aiofiles==23.2.1
# Hashing and security
hashlib
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Development
pytest==7.4.4
pytest-asyncio==0.23.3
black==23.12.1
ruff==0.1.11

88
docker-compose.yml Normal file
View File

@@ -0,0 +1,88 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: mulita-frontend
ports:
- "3000:80"
depends_on:
- backend
networks:
- mulita-network
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mulita-backend
ports:
- "8000:8000"
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
depends_on:
- redis
networks:
- mulita-network
restart: unless-stopped
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mulita-worker
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- CELERYD_CONCURRENCY=4
depends_on:
- redis
- backend
networks:
- mulita-network
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: mulita-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- mulita-network
restart: unless-stopped
command: redis-server --appendonly yes
networks:
mulita-network:
driver: bridge
volumes:
thumbs_data:
db_data:
trash_data:
redis_data:

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mulita - Photo Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for real-time updates
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Thumbnail serving with X-Accel-Redirect
location /internal_thumbs/ {
internal;
alias /data/thumbs/;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

61
frontend/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "mulita-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"zustand": "^4.4.7",
"framer-motion": "^10.18.0",
"axios": "^1.6.5",
"date-fns": "^3.2.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"react-hotkeys-hook": "^4.4.3",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"lucide-react": "^0.303.0",
"react-intersection-observer": "^9.5.3"
},
"devDependencies": {
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@types/leaflet": "^1.9.8",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

59
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
import { TopBar } from './components/layout/TopBar'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
// Set up global keyboard shortcuts
useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
})
// Show right sidebar when photos are selected
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false)
}
return (
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
<div
className={`transition-all duration-200 ${
leftSidebarOpen ? 'w-64' : 'w-0'
} overflow-hidden border-r border-border bg-surface`}
>
<LeftSidebar />
</div>
{/* Main Content - Timeline */}
<div className="flex-1 overflow-auto">
<Timeline />
</div>
{/* Right Sidebar */}
<div
className={`transition-all duration-200 ${
rightSidebarOpen ? 'w-80' : 'w-0'
} overflow-hidden border-l border-border bg-surface`}
>
<RightSidebar />
</div>
</div>
</div>
)
}
export default App

43
frontend/src/index.css Normal file
View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-bg: #111110;
--color-surface: #161615;
--color-surface-2: #1c1c1a;
--color-surface-offset: #222220;
--color-border: rgba(255, 255, 255, 0.08);
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #4f98a3;
--color-pick: #4f9e5c;
--color-reject: #c25a5a;
--color-star: #d4a340;
}
body {
@apply bg-bg text-text;
font-family: 'Geist', system-ui, sans-serif;
}
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-surface;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-offset rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-text-faint;
}

24
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,51 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark-first color scheme for photo apps
bg: '#111110',
surface: '#161615',
'surface-2': '#1c1c1a',
'surface-offset': '#222220',
border: 'rgba(255,255,255,0.08)',
text: '#e8e6e0',
'text-muted': '#878580',
'text-faint': '#4a4845',
primary: '#4f98a3', // desaturated teal
pick: '#4f9e5c', // green for picked
reject: '#c25a5a', // red for rejected
star: '#d4a340', // amber for stars
},
fontFamily: {
sans: ['Geist', 'system-ui', 'sans-serif'],
mono: ['Geist Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'shimmer': 'shimmer 2s infinite linear',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
},
},
plugins: [],
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

30
mulita.yml Normal file
View File

@@ -0,0 +1,30 @@
# Mulita configuration file
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/WebP quality
format: webp # output format for thumbs
scanner:
watch: true # use watchfiles inotify
initial_scan_on_start: true
batch_size: 100
concurrent_workers: 4
trash:
path: /data/trash
auto_empty_days: 30 # auto-delete after 30 days in trash
performance:
max_concurrent_thumbnails: 10
cache_ttl: 3600
db_pool_size: 20
db_pool_recycle: 3600

753
photovault-app-prompt.md Normal file
View File

@@ -0,0 +1,753 @@
# 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 |
| `15` | 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 `<video>` player with controls, muted autoplay of preview, unmute toggle
### 6.12 Trash View
- Accessible via "Trash" node in sidebar
- Same grid layout, same filters, same shortcuts
- Extra actions in bulk selection bar: Restore, Permanently Delete
- "Empty Trash" button at top with confirmation dialog showing count + total size
### 6.13 Library Scan Progress
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
---
## 7. Media Handling
### 7.1 Supported Formats
| Category | Formats |
|----------|---------|
| JPEG | `.jpg`, `.jpeg` |
| PNG | `.png` |
| TIFF | `.tif`, `.tiff` |
| WebP | `.webp` |
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
| RAW — Canon | `.cr2`, `.cr3` |
| RAW — Nikon | `.nef`, `.nrw` |
| RAW — Sony | `.arw`, `.srf` |
| RAW — Fuji | `.raf` |
| RAW — Panasonic | `.rw2` |
| RAW — Olympus | `.orf` |
| RAW — Samsung | `.srw` |
| RAW — Pentax | `.pef` |
| RAW — Leica | `.rwl`, `.dng` |
| RAW — DNG (universal) | `.dng` |
| RAW — Others | via rawpy (libraw) fallback |
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
### 7.2 Thumbnail Generation Pipeline
For each photo during indexing:
1. Detect format by extension + magic bytes
2. Decode to in-memory RGB image:
- JPEG/PNG/TIFF/WebP → pyvips native
- HEIC/HEIF → pillow-heif → pyvips
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
- Video → ffmpeg extract frame at 10% duration → pyvips
3. Auto-rotate by EXIF orientation
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
### 7.3 Metadata Extraction
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
- GPS coordinates if present
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
- Denormalize key fields to FTS table for search
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
---
## 8. File Operations
All file operations that touch disk must:
1. Validate target path is within a known source_root (prevent path traversal)
2. Execute atomically where possible (temp file + rename)
3. Update DB after successful disk operation (never before)
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
### Operations
| Operation | Disk action | DB action |
|-----------|-------------|-----------|
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
| Copy photos | `shutil.copy2` | Insert new photo record |
| Rename file | `os.rename` | Update `filepath`, `filename` |
| Rename folder | `os.rename` | Update folder `path` recursively |
| Create folder | `os.makedirs` | Insert folder record |
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
---
## 9. Performance Requirements
- **Initial page load**: < 2s (LCP)
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
- **Search**: < 200ms for FTS5 query on 100k photos
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
---
## 10. UI Design System
### 10.1 Aesthetic
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
- **Accent**: Desaturated teal — does not compete with photo colors
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
### 10.2 Key UI Components (shadcn/ui)
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
Build custom components:
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
- `<TimelineGroup>` — sticky date header + grid of thumbnails
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
- `<FilmStrip>` — horizontal scrollable strip for loupe view
- `<StarRating>` — interactive 0-5 stars
- `<ColorLabel>` — 7-state color dot picker
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
- `<FolderTreeNode>` — recursive folder tree item with context menu
- `<HeapItem>` — heap list item with active indicator
- `<FilterChip>` — removable active filter chip
- `<ProgressBar>` — scan progress in top bar
- `<ShortcutReference>``?` overlay showing all shortcuts in a modal
### 10.3 Color Scheme Variables
```css
/* Dark mode (default for photo apps) */
--color-bg: #111110;
--color-surface: #161615;
--color-surface-2: #1c1c1a;
--color-surface-offset: #222220;
--color-border: rgba(255,255,255,0.08);
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #4f98a3; /* desaturated teal */
--color-pick: #4f9e5c; /* green for picked */
--color-reject: #c25a5a; /* red for rejected */
--color-star: #d4a340; /* amber for stars */
```
---
## 11. Error States & Edge Cases
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
---
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
These must not be built now but the architecture must not block them:
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
4. **Duplicate Finder** — perceptual hash (pHash) across library
5. **Export Presets** — resize + watermark + format conversion on export
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
7. **Mobile PWA** — service worker, offline thumbnail caching
---
## 13. Docker Compose File Structure
```
photovault/
├── docker-compose.yml
├── photovault.yml ← user config
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── store/ ← Zustand stores
│ ├── components/
│ │ ├── layout/
│ │ ├── timeline/
│ │ ├── loupe/
│ │ ├── sidebar/
│ │ ├── metadata/
│ │ └── shared/
│ ├── hooks/
│ ├── api/ ← TanStack Query hooks + axios client
│ └── lib/
│ └── shortcuts.ts ← global keyboard shortcut registry
└── backend/
├── Dockerfile
├── requirements.txt
├── alembic/
├── app/
│ ├── main.py ← FastAPI app
│ ├── config.py ← pydantic settings
│ ├── database.py ← SQLAlchemy async engine
│ ├── models/ ← SQLAlchemy ORM models
│ ├── schemas/ ← Pydantic request/response schemas
│ ├── routers/ ← FastAPI routers per domain
│ │ ├── photos.py
│ │ ├── folders.py
│ │ ├── heaps.py
│ │ ├── tags.py
│ │ ├── trash.py
│ │ └── library.py
│ ├── services/ ← Business logic
│ │ ├── scanner.py
│ │ ├── thumbnailer.py
│ │ ├── metadata.py
│ │ └── file_ops.py
│ └── tasks/ ← Celery tasks
│ ├── celery.py
│ ├── scan.py
│ └── thumbs.py
└── nginx.conf ← X-Accel-Redirect for thumb serving
```
---
## 14. Implementation Priorities
Build in this order to get a working MVP as fast as possible:
1. **Docker Compose skeleton** — all services up, health checks passing
2. **DB schema + Alembic migration**
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
6. **Frontend: keyboard navigation + selection**
7. **Frontend: left sidebar (folder tree + heaps)**
8. **Frontend: right sidebar (metadata panel) + EXIF display**
9. **Filter bar + search**
10. **Loupe view with filmstrip**
11. **File operations: move, copy, rename, trash, restore**
12. **Metadata editing: title, notes, rating, color label, tags**
13. **Heaps: create, populate, convert to folder**
14. **Trash view + permanent delete**
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**