Add wordarr: request & import manager for ebooks, comics and audiobooks

FastAPI + SQLite backend with vanilla-JS web UI. Requests via Open
Library / Audible / AniList metadata search (or manual entry) with
per-request target library selection. Manual import flow: scan the
download dir, fuzzy-match files against missing requests, review,
then move+rename into the library per configurable naming templates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Steppenstreuner
2026-08-28 12:34:07 +02:00
co-authored by Claude Fable 5
parent 3789ad0a37
commit 508ae5f26e
30 changed files with 1368 additions and 0 deletions
View File
+63
View File
@@ -0,0 +1,63 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
from ..db import BookRequest, get_session
from ..importer import matcher, mover, scanner
router = APIRouter(prefix="/api/import", tags=["import"])
@router.get("/scan")
def scan(session: Session = Depends(get_session)):
"""Scan the download dir and suggest matches against open requests."""
items = scanner.scan(Path(config.DOWNLOAD_DIR))
missing = session.scalars(
select(BookRequest).where(BookRequest.status == "missing")
).all()
return {
"download_dir": str(config.DOWNLOAD_DIR),
"items": matcher.best_matches(items, missing),
}
class ImportItem(BaseModel):
path: str
is_dir: bool
files: list[str]
request_id: int
class ImportIn(BaseModel):
items: list[ImportItem]
@router.post("")
def do_import(data: ImportIn, session: Session = Depends(get_session)):
results = []
download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items:
req = session.get(BookRequest, item.request_id)
if not req or req.status != "missing":
results.append({"path": item.path, "ok": False, "error": "request not found or not missing"})
continue
src = Path(item.path).resolve()
if download_root not in src.parents and src != download_root:
results.append({"path": item.path, "ok": False, "error": "path outside download dir"})
continue
try:
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc:
results.append({"path": item.path, "ok": False, "error": str(exc)})
continue
req.status = "imported"
req.imported_path = dest
session.commit()
results.append({"path": item.path, "ok": True, "dest": dest})
if not results:
raise HTTPException(400, "nothing to import")
return {"results": results}
+78
View File
@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
from ..db import Library, get_session
router = APIRouter(prefix="/api/libraries", tags=["libraries"])
class LibraryIn(BaseModel):
name: str
media_type: str # ebook | audiobook | comic
root_path: str
folder_template: str | None = None
file_template: str | None = None
class LibraryOut(LibraryIn):
id: int
class Config:
from_attributes = True
def _validate(data: LibraryIn):
if data.media_type not in config.DEFAULT_NAMING:
raise HTTPException(400, f"invalid media_type: {data.media_type}")
@router.get("", response_model=list[LibraryOut])
def list_libraries(session: Session = Depends(get_session)):
return session.scalars(select(Library).order_by(Library.media_type, Library.name)).all()
@router.post("", response_model=LibraryOut)
def create_library(data: LibraryIn, session: Session = Depends(get_session)):
_validate(data)
defaults = config.DEFAULT_NAMING[data.media_type]
lib = Library(
name=data.name,
media_type=data.media_type,
root_path=data.root_path,
folder_template=data.folder_template or defaults["folder"],
file_template=data.file_template or defaults["file"],
)
session.add(lib)
session.commit()
return lib
@router.put("/{library_id}", response_model=LibraryOut)
def update_library(library_id: int, data: LibraryIn, session: Session = Depends(get_session)):
_validate(data)
lib = session.get(Library, library_id)
if not lib:
raise HTTPException(404, "library not found")
defaults = config.DEFAULT_NAMING[data.media_type]
lib.name = data.name
lib.media_type = data.media_type
lib.root_path = data.root_path
lib.folder_template = data.folder_template or defaults["folder"]
lib.file_template = data.file_template or defaults["file"]
session.commit()
return lib
@router.delete("/{library_id}")
def delete_library(library_id: int, session: Session = Depends(get_session)):
lib = session.get(Library, library_id)
if not lib:
raise HTTPException(404, "library not found")
if lib.requests:
raise HTTPException(409, "library has requests; delete them first")
session.delete(lib)
session.commit()
return {"ok": True}
+71
View File
@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..db import BookRequest, Library, get_session
router = APIRouter(prefix="/api/requests", tags=["requests"])
class RequestIn(BaseModel):
library_id: int
title: str
authors: str = ""
external_id: str = ""
year: int | None = None
series: str = ""
volume: int | None = None
cover_url: str = ""
class RequestOut(RequestIn):
id: int
media_type: str
status: str
imported_path: str
library_name: str = ""
class Config:
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, session: Session = Depends(get_session)):
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
req = BookRequest(media_type=lib.media_type, **data.model_dump())
session.add(req)
session.commit()
session.refresh(req)
return _out(req)
@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}
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, HTTPException
from ..metadata import PROVIDERS
from ..metadata.base import MetadataResult
router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("", response_model=list[MetadataResult])
async def search(media_type: str, q: str):
provider = PROVIDERS.get(media_type)
if not provider:
raise HTTPException(400, f"invalid media_type: {media_type}")
try:
return await provider(q)
except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}")