feat: timeline and folder import
This commit is contained in:
@@ -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}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
15
backend/app/tasks/__init__.py
Normal file
15
backend/app/tasks/__init__.py
Normal 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'
|
||||
]
|
||||
Reference in New Issue
Block a user