diff --git a/static/app.js b/static/app.js
index 63096e9..efce031 100644
--- a/static/app.js
+++ b/static/app.js
@@ -93,7 +93,7 @@ async function refreshMissingBadge() {
// ---- navigation ----
// the view lives in the hash so reload, back button and links keep it
-const VIEWS = ["search", "missing", "imported", "import", "settings"];
+const VIEWS = ["search", "missing", "imported", "import", "activity", "settings"];
function showView() {
const hash = location.hash.slice(1);
@@ -101,6 +101,7 @@ function showView() {
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
if (name === "missing" || name === "imported") loadRequests(name);
+ if (name === "activity") loadHistory();
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
}
@@ -622,6 +623,39 @@ $("#imported-search").addEventListener("input", () => renderRequests("imported")
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
+// ---- activity ----
+let historyRows = [];
+const HISTORY_STATE = { imported: "done", appended: "done", failed: "failed" };
+
+async function loadHistory() {
+ try {
+ historyRows = await api("/api/history");
+ } catch (err) {
+ toast(err.message, true);
+ }
+ renderHistory();
+}
+
+function renderHistory() {
+ const tbody = $("#history-table tbody");
+ if (!historyRows.length) {
+ tbody.innerHTML = `
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py
index 68e4f4f..7828337 100644
--- a/tests/test_import_flow.py
+++ b/tests/test_import_flow.py
@@ -1185,3 +1185,38 @@ def test_version_endpoint_reports_a_build_stamp(client):
assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
+
+
+def test_history_records_what_happened_to_a_request(client):
+ root = client.tmp_path / "library" / "hist"
+ lib_id = client.post("/api/libraries", json={
+ "name": "hist", "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"]
+
+ gone = client.downloads / "gone.epub"
+ res = client.post("/api/import", json={"items": [{
+ "path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
+ }]}).json()["results"][0]
+ assert not res["ok"]
+
+ src = client.downloads / "Momo.epub"
+ src.write_bytes(b"x")
+ dest = client.post("/api/import", json={"items": [{
+ "path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
+ }]}).json()["results"][0]["dest"]
+ client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
+ assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
+ # a relocate that moves nothing is not worth a row
+ client.post(f"/api/requests/{req_id}/relocate")
+ client.delete(f"/api/requests/{req_id}")
+
+ rows = client.get("/api/history").json()
+ assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
+ assert all(r["request_id"] == req_id for r in rows)
+ assert rows[0]["title"] == "Momo 2" # survives the deleted request
+ assert rows[2]["dest"] == dest
+ assert rows[3]["message"]
+ assert rows[0]["at"].endswith("+00:00")
diff --git a/wordarr/api/history.py b/wordarr/api/history.py
new file mode 100644
index 0000000..6c83085
--- /dev/null
+++ b/wordarr/api/history.py
@@ -0,0 +1,36 @@
+import datetime
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, ConfigDict, field_serializer
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from ..db import History, get_session
+
+router = APIRouter(prefix="/api/history", tags=["history"])
+
+
+class HistoryOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ at: datetime.datetime
+ event: str
+ request_id: int | None
+ title: str
+ media_type: str
+ source: str
+ dest: str
+ message: str
+
+ # SQLite drops the zone, and a bare timestamp reads as local time in JS
+ @field_serializer("at")
+ def _utc(self, at: datetime.datetime) -> str:
+ return at.replace(tzinfo=datetime.UTC).isoformat()
+
+
+@router.get("", response_model=list[HistoryOut])
+def list_history(limit: int = 200, session: Session = Depends(get_session)):
+ """Newest first."""
+ stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
+ return session.scalars(stmt).all()
diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py
index 44e24f1..b6bea36 100644
--- a/wordarr/api/imports.py
+++ b/wordarr/api/imports.py
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
-from ..db import BookRequest, get_session
+from ..db import BookRequest, get_session, record
from ..fsnames import decode_path, readable
from ..importer import matcher, mover, scanner
@@ -91,9 +91,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try:
dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc:
+ record(session, "failed", req, source=shown, message=str(exc))
+ session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.imported_path = dest
+ record(session, "appended", req, source=shown, dest=dest)
session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue
@@ -111,10 +114,13 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
+ record(session, "failed", req, source=shown, message=str(exc))
+ session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.status = "imported"
req.imported_path = dest
+ record(session, "imported", req, source=shown, dest=dest)
session.commit()
results.append({"path": shown, "ok": True, "dest": dest})
if not results:
diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py
index 49e8b9e..73f3eb9 100644
--- a/wordarr/api/requests.py
+++ b/wordarr/api/requests.py
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config
-from ..db import BookRequest, Library, get_session
+from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger
router = APIRouter(prefix="/api/requests", tags=["requests"])
@@ -213,9 +213,13 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
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))
+ record(session, "retagged", req, dest=str(path))
+ session.commit()
return {"ok": True, "files": len(files)}
if path.is_file():
tagger.tag_audio(path, req)
+ record(session, "retagged", req, dest=str(path))
+ session.commit()
return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}")
@@ -237,6 +241,8 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc:
raise HTTPException(409, str(exc))
req.imported_path = dest
+ if dest != old:
+ record(session, "relocated", req, source=old, dest=dest)
session.commit()
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@@ -246,6 +252,7 @@ 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")
+ record(session, "deleted", req, source=req.imported_path)
session.delete(req)
session.commit()
return {"ok": True}
diff --git a/wordarr/db.py b/wordarr/db.py
index 38bc2c9..b2dc9d9 100644
--- a/wordarr/db.py
+++ b/wordarr/db.py
@@ -48,6 +48,32 @@ class BookRequest(Base):
library: Mapped[Library] = relationship(back_populates="requests")
+class History(Base):
+ """One row per thing done to a request. Title and type are copied, and
+ there is no foreign key, so an entry outlives a deleted request."""
+
+ __tablename__ = "history"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ at: Mapped[datetime.datetime] = mapped_column(
+ default=lambda: datetime.datetime.now(datetime.UTC)
+ )
+ # imported | appended | relocated | retagged | deleted | failed
+ event: Mapped[str] = mapped_column(String)
+ request_id: Mapped[int | None] = mapped_column(default=None)
+ title: Mapped[str] = mapped_column(String)
+ media_type: Mapped[str] = mapped_column(String)
+ source: Mapped[str] = mapped_column(String, default="")
+ dest: Mapped[str] = mapped_column(String, default="")
+ message: Mapped[str] = mapped_column(String, default="")
+
+
+def record(session, event: str, req: BookRequest, **fields: str) -> None:
+ """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,
+ media_type=req.media_type, **fields))
+
+
_engine = None
SessionLocal = None
diff --git a/wordarr/main.py b/wordarr/main.py
index a1515d7..c9cd3c3 100644
--- a/wordarr/main.py
+++ b/wordarr/main.py
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import db, version
-from .api import fs, imports, libraries, requests, search
+from .api import fs, history, imports, libraries, requests, search
@asynccontextmanager
@@ -21,6 +21,7 @@ app.include_router(search.router)
app.include_router(requests.router)
app.include_router(imports.router)
app.include_router(fs.router)
+app.include_router(history.router)
@app.get("/api/version")