Files
wordarr/wordarr/api/requests.py
T

310 lines
11 KiB
Python

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}