from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database import get_db from app.models import Library, MediaItem from app.schemas import LibraryCreate, LibraryOut, BrowseResult, BrowseEntry from app.services import scanner, watcher as watcher_service router = APIRouter(prefix="/libraries", tags=["libraries"]) @router.get("", response_model=list[LibraryOut]) async def list_libraries(db: AsyncSession = Depends(get_db)): result = await db.execute(select(Library)) return result.scalars().all() @router.post("", response_model=LibraryOut, status_code=201) async def create_library( body: LibraryCreate, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), ): path = Path(body.path) if not path.is_dir(): raise HTTPException(400, f"Path does not exist or is not a directory: {body.path}") existing = await db.execute(select(Library).where(Library.path == str(path))) if existing.scalars().first(): raise HTTPException(409, "A library with this path already exists") lib = Library(name=body.name, path=str(path)) db.add(lib) await db.commit() await db.refresh(lib) # Scan runs in the background so the HTTP response returns immediately background_tasks.add_task(scanner.scan_library_background, lib.id, lib.path) watcher_service.start_watcher(lib.id, lib.path) return lib @router.get("/{library_id}/scan-status") async def get_scan_status(library_id: int): return {"scanning": scanner.is_scanning(library_id)} @router.post("/{library_id}/rescan") async def rescan_library( library_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Library).where(Library.id == library_id)) lib = result.scalars().first() if not lib: raise HTTPException(404, "Library not found") if scanner.is_scanning(library_id): raise HTTPException(409, "Scan already in progress") background_tasks.add_task(scanner.scan_library_background, lib.id, lib.path) return {"scanning": True} @router.delete("/{library_id}", status_code=204) async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)): result = await db.execute(select(Library).where(Library.id == library_id)) lib = result.scalars().first() if not lib: raise HTTPException(404, "Library not found") watcher_service.stop_watcher(library_id) await db.delete(lib) await db.commit() @router.get("/{library_id}/browse", response_model=BrowseResult) async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)): result = await db.execute(select(Library).where(Library.id == library_id)) lib = result.scalars().first() if not lib: raise HTTPException(404, "Library not found") root = Path(lib.path) target = (root / path).resolve() # Path traversal guard if not str(target).startswith(str(root)): raise HTTPException(400, "Invalid path") if not target.is_dir(): raise HTTPException(404, "Directory not found") items_result = await db.execute( select(MediaItem).where( MediaItem.library_id == library_id, MediaItem.missing == False, # noqa: E712 ) ) db_items: dict[str, MediaItem] = {} for item in items_result.scalars().all(): db_items[item.rel_path] = item from app.services.scanner import classify entries: list[BrowseEntry] = [] for entry in sorted((e for e in target.iterdir() if not e.name.startswith(".")), key=lambda e: (e.is_file(), e.name.lower())): rel_entry = str(entry.relative_to(root)) if entry.is_dir(): entries.append(BrowseEntry(name=entry.name, type="dir", rel_path=rel_entry)) elif entry.is_file(): db_item = db_items.get(rel_entry) if db_item: entries.append(BrowseEntry( name=entry.name, type=db_item.media_type, rel_path=rel_entry, media_item_id=db_item.id, )) else: # File exists on disk but scan hasn't indexed it yet; show by extension media_type = classify(entry) if media_type: entries.append(BrowseEntry( name=entry.name, type=media_type, rel_path=rel_entry, media_item_id=None, )) return BrowseResult(path=path, entries=entries)