feat: structure
This commit is contained in:
33
backend/app/routers/folders.py
Normal file
33
backend/app/routers/folders.py
Normal 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}"}
|
||||
27
backend/app/routers/heaps.py
Normal file
27
backend/app/routers/heaps.py
Normal 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
|
||||
61
backend/app/routers/library.py
Normal file
61
backend/app/routers/library.py
Normal 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
|
||||
}
|
||||
310
backend/app/routers/photos.py
Normal file
310
backend/app/routers/photos.py
Normal 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"
|
||||
}
|
||||
27
backend/app/routers/tags.py
Normal file
27
backend/app/routers/tags.py
Normal 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
|
||||
50
backend/app/routers/trash.py
Normal file
50
backend/app/routers/trash.py
Normal 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)}
|
||||
Reference in New Issue
Block a user