Files
wordarr/wordarr/api/requests.py
T

252 lines
8.4 KiB
Python

from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
from ..db import BookRequest, Library, get_session
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.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, 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))
return {"ok": True, "files": len(files)}
if path.is_file():
tagger.tag_audio(path, req)
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, 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
session.commit()
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@router.delete("/{request_id}")
def delete_request(request_id: int, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
if not req:
raise HTTPException(404, "request not found")
session.delete(req)
session.commit()
return {"ok": True}