feat(connect): scan audiobookshelf and call a webhook after changes

This commit is contained in:
Steppenstreuner committed 2026-10-25 19:31:02 +01:00
1 parent 40a829c80c
commit f7f89a3cc7
6 files changed
+144 -21

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` | | `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped | | `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry | | `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_ABS_URL` | – | Audiobookshelf base URL, e.g. `http://audiobookshelf`; after an import, relocate or retag it scans the libraries whose folders hold the files, so both containers must mount them under the same path |
| `WORDARR_ABS_TOKEN` | – | API token of an Audiobookshelf admin (only admins may start a scan) |
| `WORDARR_WEBHOOK_URL` | – | gets every history row as JSON (`event`, `request_id`, `title`, `media_type`, `source`, `dest`, `message`) |
| `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image | | `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest The header shows the running version and, behind it, the build stamp (the newest
+50
View File
@@ -1220,3 +1220,53 @@ def test_history_records_what_happened_to_a_request(client):
assert rows[2]["dest"] == dest assert rows[2]["dest"] == dest
assert rows[3]["message"] assert rows[3]["message"]
assert rows[0]["at"].endswith("+00:00") assert rows[0]["at"].endswith("+00:00")
def test_connections_hear_about_committed_rows(client, monkeypatch):
import httpx
from wordarr import connect
calls = []
def handler(request):
calls.append((request.method, str(request.url)))
if request.url.path == "/api/libraries":
return httpx.Response(200, json={"libraries": [
{"id": "lib-audio", "name": "Audio",
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
]})
if request.url.host == "hook":
return httpx.Response(500) # a broken webhook must not stop the scan
return httpx.Response(200)
real_client = httpx.Client
monkeypatch.setattr(connect.httpx, "Client",
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
monkeypatch.setattr(connect, "WEBHOOK_URL", "http://hook/in")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "t")
root = client.tmp_path / "library" / "conn"
lib_id = client.post("/api/libraries", json={
"name": "conn", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
assert client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"]
assert calls == [
("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"),
]
calls.clear()
client.delete(f"/api/requests/{req_id}")
assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan
+10 -8
View File
@@ -1,11 +1,11 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config, connect
from ..db import BookRequest, get_session, record from ..db import BookRequest, get_session, record
from ..fsnames import decode_path, readable from ..fsnames import decode_path, readable
from ..importer import matcher, mover, scanner from ..importer import matcher, mover, scanner
@@ -58,8 +58,9 @@ class ImportIn(BaseModel):
@router.post("") @router.post("")
def do_import(data: ImportIn, session: Session = Depends(get_session)): def do_import(data: ImportIn, background: BackgroundTasks,
results = [] session: Session = Depends(get_session)):
results, rows = [], []
download_root = Path(config.DOWNLOAD_DIR).resolve() download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items: for item in data.items:
# the client hands back whatever the scan produced, base64 included # the client hands back whatever the scan produced, base64 included
@@ -91,12 +92,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try: try:
dest = mover.append_to_import(item.files, req, req.library) dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc: except Exception as exc:
record(session, "failed", req, source=shown, message=str(exc)) rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit() session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.imported_path = dest req.imported_path = dest
record(session, "appended", req, source=shown, dest=dest) rows.append(record(session, "appended", req, source=shown, dest=dest))
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True}) results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue continue
@@ -114,15 +115,16 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library) dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc: except Exception as exc:
session.rollback() # the next commit must not keep the folder's series session.rollback() # the next commit must not keep the folder's series
record(session, "failed", req, source=shown, message=str(exc)) rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit() session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.status = "imported" req.status = "imported"
req.imported_path = dest req.imported_path = dest
record(session, "imported", req, source=shown, dest=dest) rows.append(record(session, "imported", req, source=shown, dest=dest))
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest}) results.append({"path": shown, "ok": True, "dest": dest})
if not results: if not results:
raise HTTPException(400, "nothing to import") raise HTTPException(400, "nothing to import")
connect.schedule(background, rows)
return {"results": results} return {"results": results}
+16 -10
View File
@@ -1,11 +1,11 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config, connect
from ..db import BookRequest, Library, get_session, record from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger from ..importer import mover, tagger
@@ -193,7 +193,8 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
@router.post("/{request_id}/retag") @router.post("/{request_id}/retag")
def retag_request(request_id: int, session: Session = Depends(get_session)): 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 """Rewrite the audio tags of an already imported request from its current
metadata (e.g. after adding series/volume/narrator later).""" metadata (e.g. after adding series/volume/narrator later)."""
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
@@ -213,19 +214,22 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
raise HTTPException(404, f"no audio files in {path}") raise HTTPException(404, f"no audio files in {path}")
for i, f in enumerate(files, 1): for i, f in enumerate(files, 1):
tagger.tag_audio(f, req, track=i, total=len(files)) tagger.tag_audio(f, req, track=i, total=len(files))
record(session, "retagged", req, dest=str(path)) row = record(session, "retagged", req, dest=str(path))
session.commit() session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": len(files)} return {"ok": True, "files": len(files)}
if path.is_file(): if path.is_file():
tagger.tag_audio(path, req) tagger.tag_audio(path, req)
record(session, "retagged", req, dest=str(path)) row = record(session, "retagged", req, dest=str(path))
session.commit() session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": 1} return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}") raise HTTPException(404, f"imported path no longer exists: {path}")
@router.post("/{request_id}/relocate") @router.post("/{request_id}/relocate")
def relocate_request(request_id: int, session: Session = Depends(get_session)): 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 """Re-apply the library's naming scheme to an imported title, e.g. after
fixing a series that Audible had filed differently.""" fixing a series that Audible had filed differently."""
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
@@ -241,18 +245,20 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc: except FileExistsError as exc:
raise HTTPException(409, str(exc)) raise HTTPException(409, str(exc))
req.imported_path = dest req.imported_path = dest
if dest != old: rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
record(session, "relocated", req, source=old, dest=dest)
session.commit() session.commit()
connect.schedule(background, rows)
return {"ok": True, "moved": dest != old, "from": old, "dest": dest} return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@router.delete("/{request_id}") @router.delete("/{request_id}")
def delete_request(request_id: int, session: Session = Depends(get_session)): def delete_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
if not req: if not req:
raise HTTPException(404, "request not found") raise HTTPException(404, "request not found")
record(session, "deleted", req, source=req.imported_path) row = record(session, "deleted", req, source=req.imported_path)
session.delete(req) session.delete(req)
session.commit() session.commit()
connect.schedule(background, [row])
return {"ok": True} return {"ok": True}
+60
View File
@@ -0,0 +1,60 @@
"""Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new
files. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log."""
import logging
import os
import httpx
from fastapi import BackgroundTasks
from .db import History
ABS_URL = os.environ.get("WORDARR_ABS_URL", "").strip().rstrip("/")
ABS_TOKEN = os.environ.get("WORDARR_ABS_TOKEN", "").strip()
WEBHOOK_URL = os.environ.get("WORDARR_WEBHOOK_URL", "").strip()
log = logging.getLogger(__name__)
def schedule(background: BackgroundTasks, rows: list[History]) -> None:
"""Queue the notification; it runs after the response is sent."""
events = [
{"event": r.event, "request_id": r.request_id, "title": r.title,
"media_type": r.media_type, "source": r.source, "dest": r.dest,
"message": r.message}
for r in rows
]
if events:
background.add_task(notify, events)
def notify(events: list[dict]) -> None:
with httpx.Client(timeout=10) as client:
if WEBHOOK_URL:
for e in events:
try:
client.post(WEBHOOK_URL, json=e).raise_for_status()
except httpx.HTTPError as exc:
log.warning("webhook %s failed: %s", WEBHOOK_URL, exc)
dests = [e["dest"] for e in events if e["dest"] and e["event"] != "failed"]
if ABS_URL and ABS_TOKEN and dests:
try:
_scan_audiobookshelf(client, dests)
except httpx.HTTPError as exc:
log.warning("audiobookshelf scan failed: %s", exc)
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs
both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status()
for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []]
if any(f and (d == f or d.startswith(f + "/")) for f in folders for d in dests):
client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name"))
+5 -3
View File
@@ -68,10 +68,12 @@ class History(Base):
message: Mapped[str] = mapped_column(String, default="") message: Mapped[str] = mapped_column(String, default="")
def record(session, event: str, req: BookRequest, **fields: str) -> None: def record(session, event: str, req: BookRequest, **fields: str) -> History:
"""Add a history row; it is saved with the caller's next commit.""" """Add a history row; it is saved with the caller's next commit."""
session.add(History(event=event, request_id=req.id, title=req.title, row = History(event=event, request_id=req.id, title=req.title,
media_type=req.media_type, **fields)) media_type=req.media_type, **fields)
session.add(row)
return row
_engine = None _engine = None