From 46a0d7aba862b4ee65d1e5b641e955017855ba9d Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 6 Apr 2026 23:30:19 +0200 Subject: [PATCH] feat: structure --- .env | 23 + .gitignore | 68 +++ README.md | 128 ++++++ backend/Dockerfile | 39 ++ backend/app/config.py | 130 ++++++ backend/app/database.py | 82 ++++ backend/app/main.py | 82 ++++ backend/app/models/__init__.py | 19 + backend/app/models/embeddings.py | 17 + backend/app/models/folders.py | 43 ++ backend/app/models/heaps.py | 37 ++ backend/app/models/photos.py | 75 +++ backend/app/models/tags.py | 32 ++ backend/app/routers/folders.py | 33 ++ backend/app/routers/heaps.py | 27 ++ backend/app/routers/library.py | 61 +++ backend/app/routers/photos.py | 310 +++++++++++++ backend/app/routers/tags.py | 27 ++ backend/app/routers/trash.py | 50 ++ backend/app/schemas/photos.py | 71 +++ backend/app/services/metadata.py | 183 ++++++++ backend/app/services/scanner.py | 22 + backend/app/tasks/celery.py | 31 ++ backend/app/tasks/scan.py | 298 ++++++++++++ backend/app/tasks/thumbs.py | 277 ++++++++++++ backend/requirements.txt | 50 ++ docker-compose.yml | 88 ++++ frontend/Dockerfile | 31 ++ frontend/index.html | 13 + frontend/nginx.conf | 43 ++ frontend/package.json | 61 +++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 59 +++ frontend/src/index.css | 43 ++ frontend/src/main.tsx | 24 + frontend/tailwind.config.js | 51 +++ frontend/tsconfig.json | 31 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 22 + mulita.yml | 30 ++ photovault-app-prompt.md | 753 +++++++++++++++++++++++++++++++ 41 files changed, 3480 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/embeddings.py create mode 100644 backend/app/models/folders.py create mode 100644 backend/app/models/heaps.py create mode 100644 backend/app/models/photos.py create mode 100644 backend/app/models/tags.py create mode 100644 backend/app/routers/folders.py create mode 100644 backend/app/routers/heaps.py create mode 100644 backend/app/routers/library.py create mode 100644 backend/app/routers/photos.py create mode 100644 backend/app/routers/tags.py create mode 100644 backend/app/routers/trash.py create mode 100644 backend/app/schemas/photos.py create mode 100644 backend/app/services/metadata.py create mode 100644 backend/app/services/scanner.py create mode 100644 backend/app/tasks/celery.py create mode 100644 backend/app/tasks/scan.py create mode 100644 backend/app/tasks/thumbs.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 mulita.yml create mode 100644 photovault-app-prompt.md diff --git a/.env b/.env new file mode 100644 index 0000000..1d8a719 --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99a8a51 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c265baf --- /dev/null +++ b/README.md @@ -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 +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 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1268956 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..b9dc749 --- /dev/null +++ b/backend/app/config.py @@ -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() \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..4d54750 --- /dev/null +++ b/backend/app/database.py @@ -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") \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6ee03bf --- /dev/null +++ b/backend/app/main.py @@ -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"} \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..5c26c94 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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' +] \ No newline at end of file diff --git a/backend/app/models/embeddings.py b/backend/app/models/embeddings.py new file mode 100644 index 0000000..74d7e1b --- /dev/null +++ b/backend/app/models/embeddings.py @@ -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 \ No newline at end of file diff --git a/backend/app/models/folders.py b/backend/app/models/folders.py new file mode 100644 index 0000000..c1ce626 --- /dev/null +++ b/backend/app/models/folders.py @@ -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'), + ) \ No newline at end of file diff --git a/backend/app/models/heaps.py b/backend/app/models/heaps.py new file mode 100644 index 0000000..470fda2 --- /dev/null +++ b/backend/app/models/heaps.py @@ -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 \ No newline at end of file diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py new file mode 100644 index 0000000..730c6f5 --- /dev/null +++ b/backend/app/models/photos.py @@ -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'), + ) \ No newline at end of file diff --git a/backend/app/models/tags.py b/backend/app/models/tags.py new file mode 100644 index 0000000..a2d766a --- /dev/null +++ b/backend/app/models/tags.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py new file mode 100644 index 0000000..723e312 --- /dev/null +++ b/backend/app/routers/folders.py @@ -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}"} \ No newline at end of file diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py new file mode 100644 index 0000000..85e10cc --- /dev/null +++ b/backend/app/routers/heaps.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py new file mode 100644 index 0000000..a0638aa --- /dev/null +++ b/backend/app/routers/library.py @@ -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 + } \ No newline at end of file diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py new file mode 100644 index 0000000..d3efd4d --- /dev/null +++ b/backend/app/routers/photos.py @@ -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" + } \ No newline at end of file diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py new file mode 100644 index 0000000..e7dc234 --- /dev/null +++ b/backend/app/routers/tags.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/trash.py b/backend/app/routers/trash.py new file mode 100644 index 0000000..7f6a54e --- /dev/null +++ b/backend/app/routers/trash.py @@ -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)} \ No newline at end of file diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py new file mode 100644 index 0000000..be90fee --- /dev/null +++ b/backend/app/schemas/photos.py @@ -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) \ No newline at end of file diff --git a/backend/app/services/metadata.py b/backend/app/services/metadata.py new file mode 100644 index 0000000..a2b000d --- /dev/null +++ b/backend/app/services/metadata.py @@ -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)} \ No newline at end of file diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..56c5082 --- /dev/null +++ b/backend/app/services/scanner.py @@ -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}") \ No newline at end of file diff --git a/backend/app/tasks/celery.py b/backend/app/tasks/celery.py new file mode 100644 index 0000000..52a1798 --- /dev/null +++ b/backend/app/tasks/celery.py @@ -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, +) \ No newline at end of file diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py new file mode 100644 index 0000000..0321348 --- /dev/null +++ b/backend/app/tasks/scan.py @@ -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}") \ No newline at end of file diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py new file mode 100644 index 0000000..78cc05b --- /dev/null +++ b/backend/app/tasks/thumbs.py @@ -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)} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f59d96e --- /dev/null +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a1837f1 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..77f4910 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..98acbb3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Mulita - Photo Management + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a372a87 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5300bbd --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8c23dcb --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ + +
+ {/* Left Sidebar */} +
+ +
+ + {/* Main Content - Timeline */} +
+ +
+ + {/* Right Sidebar */} +
+ +
+
+
+ ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..278c6b3 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..052f2f4 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + , +) \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..e04efaf --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f9a81ca --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..85d2f82 --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, + }, + }, +}) \ No newline at end of file diff --git a/mulita.yml b/mulita.yml new file mode 100644 index 0000000..e916d96 --- /dev/null +++ b/mulita.yml @@ -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 \ No newline at end of file diff --git a/photovault-app-prompt.md b/photovault-app-prompt.md new file mode 100644 index 0000000..b167854 --- /dev/null +++ b/photovault-app-prompt.md @@ -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 | +| `1–5` | Set star rating | +| `0` | Remove star rating | +| `6` | Red label | +| `7` | Orange label | +| `8` | Yellow label | +| `9` | Green label | + +#### Actions +| Key | Action | +|-----|--------| +| `G` | Go to grid view | +| `E` | Go to loupe view | +| `C` | Compare view (2 selected) | +| `Tab` | Toggle left sidebar | +| `I` | Toggle right metadata sidebar | +| `\` | Toggle filter bar | +| `F` | Toggle fullscreen | +| `Delete` | Move selected to trash | +| `Shift+Delete` | Permanently delete (if in trash view) | +| `Cmd/Ctrl+Z` | Undo last action | +| `Cmd/Ctrl+Shift+Z` | Redo | +| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) | +| `Cmd/Ctrl+X` | Cut selected (for move) | +| `Cmd/Ctrl+V` | Paste into current folder | +| `T` | Add/remove from active heap | +| `Cmd/Ctrl+F` | Focus search bar | +| `/` | Focus search bar | +| `?` | Show keyboard shortcut reference overlay | + +All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea. + +### 6.5 Bulk Selection + +- **Click** — select single photo (deselects others) +- **Shift+Click** — range select from last selected to clicked +- **Cmd/Ctrl+Click** — toggle individual photo in selection +- **Cmd/Ctrl+A** — select all visible +- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export +- Bulk actions call `POST /api/v1/photos/bulk` + +### 6.6 Metadata Sidebar (Right Panel) + +When a photo is selected, the right sidebar shows: + +**Section: Preview** +- Large thumbnail (clicking opens loupe) +- Filename (editable inline, renames file on disk) +- User title (editable) +- User notes (textarea) +- Rating (5-star widget, keyboard-interactive) +- Color label (color dot picker) +- Flags: Picked / Rejected toggles + +**Section: Tags** +- Tag chips with remove button +- "Add tag" autocomplete input +- Create new tag inline + +**Section: EXIF / Metadata** +Collapsible groups: +- *Camera*: Make, Model, Serial, Lens, Firmware +- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode +- *File*: Format, Dimensions, File Size, Color Space, Bit Depth +- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand) +- *IPTC/XMP*: Copyright, Creator, Description, Keywords + +**Section: Histogram** (stretch goal) +- Live RGB+Luminosity histogram rendered from a downsampled version of the photo + +### 6.7 Filter Bar + +A collapsible horizontal bar below the top bar (shortcut `\`). Contains: + +| Control | Type | +|---------|------| +| Date range | Date range picker (from/to) | +| Media type | Multi-select chips: Photo / Video / RAW / HEIC | +| Rating | Min/max star slider | +| Color label | Color dot multi-select | +| Flags | Picked / Rejected / Unflagged toggle buttons | +| Tags | Multi-select tag dropdown (AND/OR mode toggle) | +| Camera make | Dropdown (populated from DB) | +| Lens | Dropdown (populated from DB) | + +Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks. + +### 6.8 Search + +- Magnifier icon in top bar, shortcut `/` or `Cmd+F` +- Full-text search via FTS5 backend +- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags +- Results appear inline in the current view (no separate search results page) +- Search combined with active filters (additive) + +### 6.9 Folder Tree (Left Sidebar) + +- Hierarchical tree view of all source roots and their subfolder structure +- Each folder shows photo count badge +- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path +- Drag-and-drop folders to rearrange (moves directory on disk with confirmation) +- "All Photos" virtual root node at top +- "Trash" virtual node at bottom with count badge + +### 6.10 Heaps Panel (Left Sidebar) + +- List of named heaps below folder tree +- "+ New Heap" button (creates unnamed heap, prompts for name) +- Each heap shows photo count +- Click heap → main area shows heap contents in grid +- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap +- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap. +- A persistent "current heap" pill shown in the top bar when a heap is active + +### 6.11 Loupe View + +- Single photo, full-viewport +- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display) +- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+ +- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable +- Left panel collapse, right metadata panel still accessible +- For videos: HTML5 `