155 lines
5.4 KiB
Python
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)
|