feat: structure

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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