Files
MediaLore-Web-App/backend/app/routers/libraries.py
2026-05-16 16:51:55 -04:00

155 lines
5.4 KiB
Python

from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import Library, MediaItem
from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, 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.get("/{library_id}/doom-scroll", response_model=list[MediaItemOut])
async def doom_scroll(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first():
raise HTTPException(404, "Library not found")
stmt = (
select(MediaItem)
.options(selectinload(MediaItem.tags))
.where(MediaItem.library_id == library_id, MediaItem.missing == False) # noqa: E712
)
if path:
stmt = stmt.where(MediaItem.rel_path.like(path + "/%"))
result = await db.execute(stmt)
return result.scalars().all()
@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)