""" Library API router for stats, scanning, and directory browsing """ import os 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 Photo, SourceRoot router = APIRouter() # Always-allowed root for the directory browser. Whatever the user mounts # as PHOTO_DIRS in .env shows up here. DEFAULT_LIBRARY_ROOT = "/photos" @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.get("/browse") async def browse_directory( path: str = DEFAULT_LIBRARY_ROOT, db: AsyncSession = Depends(get_db), ): """List the immediate child directories of `path` so the frontend can render a folder picker. The path is validated to live under one of the allowed roots so this can't be used to enumerate the container filesystem: - The default library mount (/photos) - Any active SourceRoot the user has already added (and its subtree) Returns: { "path": str, # canonical (normalized) path "parent": str | null, # parent path if still inside an allowed root "is_existing_root": bool # whether `path` is itself a SourceRoot "children": [ { "name", "path", "is_existing_root" }, ... ] } """ # Build the allowed-roots set: default mount + every active SourceRoot. sr_result = await db.execute( select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 ) source_roots = sr_result.scalars().all() sr_paths = [os.path.normpath(sr.path) for sr in source_roots] allowed_roots = {os.path.normpath(DEFAULT_LIBRARY_ROOT), *sr_paths} canonical = os.path.normpath(path) # Path must live under (or be) one of the allowed roots — prevents # browsing /etc, /data/db, etc. def under_allowed(p: str) -> bool: for root in allowed_roots: if p == root or p.startswith(root + os.sep): return True return False if not under_allowed(canonical): raise HTTPException( status_code=403, detail=f"Path is outside the allowed photo roots", ) if not os.path.isdir(canonical): raise HTTPException(status_code=404, detail=f"Not a directory: {canonical}") # Build the child list — only directories, hidden entries (dotfiles) # excluded. sr_path_set = set(sr_paths) try: entries = sorted(os.listdir(canonical)) except OSError as e: raise HTTPException(status_code=500, detail=f"Cannot read directory: {e}") children = [] for entry in entries: if entry.startswith('.'): continue child_path = os.path.join(canonical, entry) if not os.path.isdir(child_path): continue children.append({ "name": entry, "path": child_path, "is_existing_root": child_path in sr_path_set, }) # Compute parent path if it's still inside an allowed root. parent = os.path.normpath(os.path.dirname(canonical)) parent_in_scope = parent != canonical and under_allowed(parent) return { "path": canonical, "parent": parent if parent_in_scope else None, "is_existing_root": canonical in sr_path_set, "children": children, } @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(db: AsyncSession = Depends(get_db)): """Get current scan status""" 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 { "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 [] }