feat: timeline and folder import

This commit is contained in:
2026-04-07 00:42:22 +02:00
parent 6d1b227fb9
commit 78e12e8309
20 changed files with 1036 additions and 75 deletions

View File

@@ -4,30 +4,96 @@ Folders API router
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from typing import List, Optional
from pydantic import BaseModel
import os
import uuid
from app.database import get_db
from app.models import Folder, SourceRoot
router = APIRouter()
class FolderCreate(BaseModel):
path: str
recursive: bool = True
watch: bool = False
class FolderResponse(BaseModel):
id: str
name: str
path: str
photo_count: int
@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
"""Get all source folders"""
# Get source roots instead of regular folders
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
source_roots = result.scalars().all()
folders_list = []
for root in source_roots:
# Get photo count for this source root
folder_result = await db.execute(
select(Folder).where(Folder.source_root_id == root.id)
)
folders = folder_result.scalars().all()
photo_count = sum(f.photo_count for f in folders)
folders_list.append({
"id": root.id,
"name": root.name or os.path.basename(root.path),
"path": root.path,
"photo_count": photo_count
})
return {"folders": folders_list}
@router.post("")
async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Add a new source folder"""
# Check if path exists
if not os.path.exists(folder.path):
raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}")
# Check if path is already added
result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Path already added as source folder")
# Create source root
source_root = SourceRoot(
id=str(uuid.uuid4()),
name=os.path.basename(folder.path),
path=folder.path
)
db.add(source_root)
await db.commit()
# Automatically trigger a scan for the new folder
from app.tasks.celery import celery_app
celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
"photo_count": 0
}
@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
"""Trigger manual re-scan of source root folder"""
from app.tasks.celery import celery_app
result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = result.scalar_one_or_none()
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
scan_task.delay(folder.path)
return {"status": "success", "message": f"Scan queued for {folder.path}"}
# Queue scan task using the task name defined in the decorator
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}

View File

@@ -48,14 +48,25 @@ async def trigger_scan():
return {"status": "success", "message": "Library scan started"}
@router.get("/scan/status")
async def get_scan_status():
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""
# This would connect to Celery to get task status
# For now, return a simple response
import redis
from app.config import settings
# Connect to Redis to get scan status
r = redis.Redis.from_url(settings.redis_url)
# Get scan status from Redis (set by worker tasks)
is_scanning = r.get('scan:active') == b'true'
current_folder = r.get('scan:current_folder')
processed_files = int(r.get('scan:processed_files') or 0)
total_files = int(r.get('scan:total_files') or 0)
errors = r.lrange('scan:errors', 0, -1)
return {
"status": "idle",
"progress": 0,
"current_folder": None,
"queued": 0,
"done": 0
"is_scanning": is_scanning,
"current_folder": current_folder.decode() if current_folder else None,
"processed_files": processed_files,
"total_files": total_files,
"errors": [e.decode() for e in errors] if errors else []
}

View File

@@ -10,6 +10,9 @@ from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
@@ -164,10 +167,51 @@ async def get_thumbnail(
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
thumb_path = getattr(photo, f'thumb_{size}')
# Check if thumbnail exists, generate if not
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp"
if not thumb_path or not os.path.exists(thumb_path):
raise HTTPException(status_code=404, detail="Thumbnail not found")
if not os.path.exists(thumb_path):
# Generate thumbnail on demand
from app.tasks.thumbs import generate_thumbnails
generate_thumbnails.delay(photo_id)
# For now, return a placeholder or the original with reduced quality
if os.path.exists(photo.filepath):
from PIL import Image
try:
os.makedirs(thumb_dir, exist_ok=True)
img = Image.open(photo.filepath)
# Auto-rotate based on EXIF
from PIL import ExifTags
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = img._getexif()
if exif is not None:
orient = exif.get(orientation)
if orient == 3:
img = img.rotate(180, expand=True)
elif orient == 6:
img = img.rotate(270, expand=True)
elif orient == 8:
img = img.rotate(90, expand=True)
except:
pass
# Generate thumbnail size
sizes = {'small': 150, 'medium': 400, 'large': 800}
target_size = sizes.get(size, 400)
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
# Save as WebP
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
except Exception as e:
logger.error(f"Error generating thumbnail: {e}")
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):

View File

@@ -40,7 +40,7 @@ class PhotoResponse(PhotoBase):
exif_json: Optional[str] = None
is_duplicate: bool = False
live_photo_video_id: Optional[str] = None
tags: List[Dict[str, Any]] = []
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
class Config:
orm_mode = True

View File

@@ -0,0 +1,15 @@
"""
Celery tasks module
"""
from app.tasks.celery import celery_app
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
__all__ = [
'celery_app',
'scan_folder',
'scan_all_source_roots',
'watch_folders',
'generate_thumbnails',
'regenerate_all_thumbnails'
]