from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel, ConfigDict from sqlalchemy import func, select from sqlalchemy.orm import Session from .. import config, connect from ..db import BookRequest, Library, get_session, record from ..importer import mover, tagger router = APIRouter(prefix="/api/requests", tags=["requests"]) class BulkItem(BaseModel): title: str authors: str = "" narrator: str = "" external_id: str = "" year: int | None = None series: str = "" volume: int | None = None cover_url: str = "" class RequestIn(BulkItem): library_id: int class RequestOut(RequestIn): id: int media_type: str status: str imported_path: str library_name: str = "" model_config = ConfigDict(from_attributes=True) def _out(r: BookRequest) -> RequestOut: out = RequestOut.model_validate(r) out.library_name = r.library.name if r.library else "" return out @router.get("", response_model=list[RequestOut]) def list_requests(status: str | None = None, media_type: str | None = None, library_id: int | None = None, session: Session = Depends(get_session)): stmt = select(BookRequest).order_by(BookRequest.created_at.desc()) if status: stmt = stmt.where(BookRequest.status == status) if media_type: stmt = stmt.where(BookRequest.media_type == media_type) if library_id: stmt = stmt.where(BookRequest.library_id == library_id) return [_out(r) for r in session.scalars(stmt).all()] @router.post("", response_model=RequestOut) def create_request(data: RequestIn, allow_duplicate: bool = False, session: Session = Depends(get_session)): lib = session.get(Library, data.library_id) if not lib: raise HTTPException(404, "library not found") if not allow_duplicate: open_reqs = session.scalars( select(BookRequest).where( BookRequest.status == "missing", BookRequest.media_type == lib.media_type, ) ).all() for r in open_reqs: same_id = data.external_id and r.external_id == data.external_id same_title = ( r.title.strip().lower() == data.title.strip().lower() and (r.narrator or "") == (data.narrator or "") and r.volume == data.volume ) if same_id or same_title: raise HTTPException( 409, f"Bereits als missing vorhanden: „{r.title}“ (#{r.id})" ) req = BookRequest(media_type=lib.media_type, **data.model_dump()) session.add(req) session.commit() session.refresh(req) return _out(req) class BulkRequestIn(BaseModel): library_id: int series: str authors: str = "" narrator: str = "" volume_from: int volume_to: int year: int | None = None cover_url: str = "" @router.post("/bulk", response_model=list[RequestOut]) def create_bulk(data: BulkRequestIn, session: Session = Depends(get_session)): lib = session.get(Library, data.library_id) if not lib: raise HTTPException(404, "library not found") if data.volume_from > data.volume_to: raise HTTPException(400, "volume_from must be <= volume_to") if data.volume_to - data.volume_from > 999: raise HTTPException(400, "at most 1000 requests per bulk call") width = max(2, len(str(data.volume_to))) reqs = [] for n in range(data.volume_from, data.volume_to + 1): req = BookRequest( media_type=lib.media_type, library_id=lib.id, title=f"{data.series} Folge {n:0{width}d}", authors=data.authors, narrator=data.narrator, series=data.series, volume=n, year=data.year, cover_url=data.cover_url, ) session.add(req) reqs.append(req) session.commit() for r in reqs: session.refresh(r) return [_out(r) for r in reqs] class BulkItemsIn(BaseModel): library_id: int items: list[BulkItem] @router.post("/bulk-items") def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)): """Create many requests at once from ready-made metadata (e.g. a whole Audible series). Entries that already exist as missing are skipped.""" lib = session.get(Library, data.library_id) if not lib: raise HTTPException(404, "library not found") if len(data.items) > 1000: raise HTTPException(400, "at most 1000 requests per bulk call") open_reqs = session.scalars( select(BookRequest).where( BookRequest.status == "missing", BookRequest.media_type == lib.media_type, ) ).all() known_ids = {r.external_id for r in open_reqs if r.external_id} known_titles = { (r.title.strip().lower(), r.narrator or "", r.volume) for r in open_reqs } created, skipped = [], 0 for item in data.items: key = (item.title.strip().lower(), item.narrator or "", item.volume) if (item.external_id and item.external_id in known_ids) or key in known_titles: skipped += 1 continue known_ids.add(item.external_id) known_titles.add(key) req = BookRequest( media_type=lib.media_type, library_id=lib.id, **item.model_dump(), ) session.add(req) created.append(req) session.commit() for r in created: session.refresh(r) return {"created": [_out(r) for r in created], "skipped": skipped} @router.get("/count") def count_requests(status: str | None = None, session: Session = Depends(get_session)): stmt = select(func.count()).select_from(BookRequest) if status: stmt = stmt.where(BookRequest.status == status) return {"count": session.scalar(stmt)} class BulkIdsIn(BaseModel): ids: list[int] class BulkUpdateIn(BulkIdsIn): library_id: int # registered before the /{request_id} routes, which would try "bulk" as an id @router.put("/bulk") def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)): """Move requests to another library. Imported files stay where they are until the request is relocated.""" lib = session.get(Library, data.library_id) if not lib: raise HTTPException(404, "library not found") reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all() if any(r.media_type != lib.media_type for r in reqs): raise HTTPException(400, "library has a different media type") for r in reqs: r.library_id = lib.id session.commit() return {"updated": len(reqs)} @router.delete("/bulk") def delete_bulk(data: BulkIdsIn, background: BackgroundTasks, session: Session = Depends(get_session)): reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all() rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs] for r in reqs: session.delete(r) session.commit() connect.schedule(background, rows) return {"deleted": len(reqs)} @router.put("/{request_id}", response_model=RequestOut) def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) if not req: raise HTTPException(404, "request not found") lib = session.get(Library, data.library_id) if not lib: raise HTTPException(404, "library not found") for field, value in data.model_dump().items(): setattr(req, field, value) req.media_type = lib.media_type session.commit() session.refresh(req) return _out(req) @router.post("/{request_id}/retag") def retag_request(request_id: int, background: BackgroundTasks, session: Session = Depends(get_session)): """Rewrite the audio tags of an already imported request from its current metadata (e.g. after adding series/volume/narrator later).""" req = session.get(BookRequest, request_id) if not req: raise HTTPException(404, "request not found") if req.media_type != "audiobook": raise HTTPException(400, "retag is only supported for audiobooks") if req.status != "imported" or not req.imported_path: raise HTTPException(400, "request is not imported yet") path = Path(req.imported_path) if path.is_dir(): files = sorted( f for f in path.iterdir() if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS ) if not files: raise HTTPException(404, f"no audio files in {path}") for i, f in enumerate(files, 1): tagger.tag_audio(f, req, track=i, total=len(files)) row = record(session, "retagged", req, dest=str(path)) session.commit() connect.schedule(background, [row]) return {"ok": True, "files": len(files)} if path.is_file(): tagger.tag_audio(path, req) row = record(session, "retagged", req, dest=str(path)) session.commit() connect.schedule(background, [row]) return {"ok": True, "files": 1} raise HTTPException(404, f"imported path no longer exists: {path}") @router.post("/{request_id}/relocate") def relocate_request(request_id: int, background: BackgroundTasks, session: Session = Depends(get_session)): """Re-apply the library's naming scheme to an imported title, e.g. after fixing a series that Audible had filed differently.""" req = session.get(BookRequest, request_id) if not req: raise HTTPException(404, "request not found") if req.status != "imported" or not req.imported_path: raise HTTPException(400, "request is not imported yet") old = req.imported_path try: dest = mover.relocate(req, req.library) except FileNotFoundError as exc: raise HTTPException(404, str(exc)) except FileExistsError as exc: raise HTTPException(409, str(exc)) req.imported_path = dest rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else [] session.commit() connect.schedule(background, rows) return {"ok": True, "moved": dest != old, "from": old, "dest": dest} @router.delete("/{request_id}") def delete_request(request_id: int, background: BackgroundTasks, session: Session = Depends(get_session)): req = session.get(BookRequest, request_id) if not req: raise HTTPException(404, "request not found") row = record(session, "deleted", req, source=req.imported_path) session.delete(req) session.commit() connect.schedule(background, [row]) return {"ok": True}