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:
co-authored by
Claude Fable 5
parent
3789ad0a37
commit
508ae5f26e
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user