From 55d7c8bdda45ae68017eb3bb40e4c598186ec765 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:17:13 +0100 Subject: [PATCH 1/2] feat(series): watch series and suggest new parts once a day --- tests/test_import_flow.py | 65 +++++++++++++++ wordarr/api/series.py | 164 ++++++++++++++++++++++++++++++++++++++ wordarr/db.py | 43 ++++++++++ wordarr/main.py | 6 +- 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 wordarr/api/series.py diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 3262a0d..abf89c8 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1359,3 +1359,68 @@ def test_recent_logs_newest_first(client): rows = client.get("/api/system/logs").json() assert [r["message"] for r in rows[:2]] == ["second", "first"] assert rows[0]["level"] == "WARNING" + + +def test_watched_series_suggests_only_new_parts(client, monkeypatch): + import asyncio + import datetime + + from wordarr import db + from wordarr.api import series + from wordarr.metadata import SERIES_PROVIDERS + from wordarr.metadata.base import MetadataResult + + def part(volume, external_id=""): + return MetadataResult(media_type="ebook", title=f"Band {volume}", volume=volume, + series="Saga", external_id=external_id, source="hardcover") + + hits = [part(1, "a1"), part(2, "a2")] + calls = [] + + async def lookup(series_id, title, language, source): + calls.append(series_id) + if hits is None: + raise RuntimeError("offline") + return hits + + monkeypatch.setitem(SERIES_PROVIDERS, "ebook", lookup) + lib = client.post("/api/libraries", json={ + "name": "Saga", "media_type": "ebook", "root_path": str(client.tmp_path)}).json() + client.post("/api/requests", json={"library_id": lib["id"], "title": "Band 3", "volume": 3}) + watch = {"media_type": "ebook", "library_id": lib["id"], "series_id": "s1", + "source": "hardcover", "title": "Saga"} + + resp = client.post("/api/series", json=watch) + assert resp.status_code == 200 + assert resp.json()["new"] == [] # what exists today is not news + assert client.post("/api/series", json=watch).status_code == 409 + + hits += [part(3), part(4, "a4"), part(5, "a5")] # 3 is requested already + [w] = client.post("/api/series/check").json() + new = {p["title"]: p["id"] for p in w["new"]} + assert set(new) == {"Band 4", "Band 5"} + + client.post("/api/series/parts", json={"ids": [new["Band 4"]], "action": "request"}) + client.post("/api/series/parts", json={"ids": [new["Band 5"]], "action": "ignore"}) + missing = client.get("/api/requests?status=missing").json() + assert {r["title"] for r in missing} == {"Band 3", "Band 4"} + assert client.post("/api/series/check").json()[0]["new"] == [] + + hits = None + [w] = client.post("/api/series/check").json() + assert w["error"] == "offline" + assert client.post("/api/series", json={**watch, "series_id": "s2"}).status_code == 502 + + # the daily run only picks up what was checked more than a day ago + hits, calls[:] = [], [] + asyncio.run(series.check_due()) + assert calls == [] + with db.SessionLocal() as session: + session.get(db.WatchedSeries, w["id"]).checked_at = ( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=2)) + session.commit() + asyncio.run(series.check_due()) + assert calls == ["s1"] + + assert client.delete(f"/api/series/{w['id']}").status_code == 200 + assert client.get("/api/series").json() == [] diff --git a/wordarr/api/series.py b/wordarr/api/series.py new file mode 100644 index 0000000..1a7117f --- /dev/null +++ b/wordarr/api/series.py @@ -0,0 +1,164 @@ +import asyncio +import datetime +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from .. import db +from ..db import BookRequest, Library, SeriesPart, WatchedSeries, get_session +from ..metadata import SERIES_PROVIDERS + +router = APIRouter(prefix="/api/series", tags=["series"]) +log = logging.getLogger(__name__) + +PART_FIELDS = ("title", "authors", "narrator", "external_id", "year", "series", + "volume", "cover_url") + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +def _key(r) -> str: + return r.external_id or f"{r.title.strip().lower()}|{r.volume}" + + +async def check_watch(session: Session, w: WatchedSeries) -> None: + """Look the series up again. The first lookup only records what exists, so + just later parts become suggestions; so do parts nobody has requested yet.""" + try: + hits = await SERIES_PROVIDERS[w.media_type](w.series_id, w.title, w.language, w.source) + except Exception as exc: + log.warning("series %r lookup failed: %s", w.title, exc) + w.error, w.checked_at = str(exc), _now() + return + initial = not w.parts + reqs = session.scalars(select(BookRequest).where(BookRequest.media_type == w.media_type)).all() + requested = {r.external_id for r in reqs if r.external_id} + requested |= {(r.title.strip().lower(), r.volume) for r in reqs} + seen = {p.key for p in w.parts} + for hit in hits: + if (key := _key(hit)) in seen: + continue + seen.add(key) + known = initial or hit.external_id in requested or ( + hit.title.strip().lower(), hit.volume) in requested + w.parts.append(SeriesPart(key=key, state="known" if known else "new", + **{f: getattr(hit, f) for f in PART_FIELDS})) + w.error, w.checked_at = "", _now() + + +async def check_due() -> None: + cutoff = (_now() - datetime.timedelta(days=1)).replace(tzinfo=None) # SQLite stores naive + with db.SessionLocal() as session: + due = session.scalars(select(WatchedSeries).where( + or_(WatchedSeries.checked_at.is_(None), WatchedSeries.checked_at < cutoff))).all() + for w in due: + await check_watch(session, w) + session.commit() + if due: + log.info("checked %d watched series", len(due)) + + +async def run_daily() -> None: + """Wakes hourly and checks what is a day old, so restarts do not reset the clock.""" + while True: + try: + await check_due() + except Exception: + log.exception("series check failed") + await asyncio.sleep(3600) + + +def _out(w: WatchedSeries) -> dict: + checked = w.checked_at and w.checked_at.replace(tzinfo=datetime.UTC).isoformat() + return { + "id": w.id, "media_type": w.media_type, "library_id": w.library_id, + "library_name": w.library.name, "series_id": w.series_id, "source": w.source, + "title": w.title, "checked_at": checked, "error": w.error, + "new": [{"id": p.id, **{f: getattr(p, f) for f in PART_FIELDS}} + for p in w.parts if p.state == "new"], + } + + +def _all(session: Session) -> list[dict]: + return [_out(w) for w in session.scalars(select(WatchedSeries).order_by(WatchedSeries.title))] + + +@router.get("") +def list_watches(session: Session = Depends(get_session)): + return _all(session) + + +class WatchIn(BaseModel): + media_type: str + library_id: int + series_id: str + source: str = "" + title: str + language: str = "" + + +@router.post("") +async def create_watch(data: WatchIn, session: Session = Depends(get_session)): + if data.media_type not in SERIES_PROVIDERS: + raise HTTPException(400, f"no series lookup for media_type: {data.media_type}") + lib = session.get(Library, data.library_id) + if not lib: + raise HTTPException(404, "library not found") + if lib.media_type != data.media_type: + raise HTTPException(400, "library has a different media type") + if session.scalar(select(WatchedSeries).where( + WatchedSeries.media_type == data.media_type, + WatchedSeries.source == data.source, + WatchedSeries.series_id == data.series_id)): + raise HTTPException(409, "series is already watched") + w = WatchedSeries(**data.model_dump()) + session.add(w) + await check_watch(session, w) + if w.error: # without a first lookup every part would turn up as new later + session.rollback() + raise HTTPException(502, f"metadata provider error: {w.error}") + session.commit() + return _out(w) + + +@router.delete("/{watch_id}") +def delete_watch(watch_id: int, session: Session = Depends(get_session)): + w = session.get(WatchedSeries, watch_id) + if not w: + raise HTTPException(404, "series not found") + session.delete(w) + session.commit() + return {"ok": True} + + +@router.post("/check") +async def check_all(session: Session = Depends(get_session)): + for w in session.scalars(select(WatchedSeries)).all(): + await check_watch(session, w) + session.commit() + return _all(session) + + +class PartsIn(BaseModel): + ids: list[int] + action: str # request | ignore + + +@router.post("/parts") +def handle_parts(data: PartsIn, session: Session = Depends(get_session)): + if data.action not in ("request", "ignore"): + raise HTTPException(400, f"invalid action: {data.action}") + parts = session.scalars(select(SeriesPart).where( + SeriesPart.id.in_(data.ids), SeriesPart.state == "new")).all() + for p in parts: + if data.action == "request": + session.add(BookRequest(media_type=p.watch.media_type, library_id=p.watch.library_id, + **{f: getattr(p, f) for f in PART_FIELDS})) + p.state = "requested" if data.action == "request" else "ignored" + session.commit() + return {"done": len(parts)} diff --git a/wordarr/db.py b/wordarr/db.py index 5784e9d..d1112f0 100644 --- a/wordarr/db.py +++ b/wordarr/db.py @@ -23,6 +23,8 @@ class Library(Base): language: Mapped[str] = mapped_column(String, default="") requests: Mapped[list["BookRequest"]] = relationship(back_populates="library") + watches: Mapped[list["WatchedSeries"]] = relationship( + back_populates="library", cascade="all, delete-orphan") class BookRequest(Base): @@ -68,6 +70,47 @@ class History(Base): message: Mapped[str] = mapped_column(String, default="") +class WatchedSeries(Base): + """A series looked up again now and then; new parts become suggestions.""" + + __tablename__ = "watched_series" + + id: Mapped[int] = mapped_column(primary_key=True) + media_type: Mapped[str] = mapped_column(String) + library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id")) + series_id: Mapped[str] = mapped_column(String) + source: Mapped[str] = mapped_column(String, default="") + title: Mapped[str] = mapped_column(String) + language: Mapped[str] = mapped_column(String, default="") + checked_at: Mapped[datetime.datetime | None] = mapped_column(default=None) + error: Mapped[str] = mapped_column(String, default="") + + library: Mapped[Library] = relationship(back_populates="watches") + parts: Mapped[list["SeriesPart"]] = relationship( + back_populates="watch", cascade="all, delete-orphan") + + +class SeriesPart(Base): + """Every part a watch has seen, so a later lookup can tell what is new.""" + + __tablename__ = "series_parts" + + id: Mapped[int] = mapped_column(primary_key=True) + watch_id: Mapped[int] = mapped_column(ForeignKey("watched_series.id")) + key: Mapped[str] = mapped_column(String) + state: Mapped[str] = mapped_column(String) # known | new | requested | ignored + title: Mapped[str] = mapped_column(String) + authors: Mapped[str] = mapped_column(String, default="") + narrator: Mapped[str] = mapped_column(String, default="") + external_id: Mapped[str] = mapped_column(String, default="") + year: Mapped[int | None] = mapped_column(default=None) + series: Mapped[str] = mapped_column(String, default="") + volume: Mapped[int | None] = mapped_column(default=None) + cover_url: Mapped[str] = mapped_column(String, default="") + + watch: Mapped[WatchedSeries] = relationship(back_populates="parts") + + def record(session, event: str, req: BookRequest, **fields: str) -> History: """Add a history row; it is saved with the caller's next commit.""" row = History(event=event, request_id=req.id, title=req.title, diff --git a/wordarr/main.py b/wordarr/main.py index b8d2e45..6adc484 100644 --- a/wordarr/main.py +++ b/wordarr/main.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import asynccontextmanager from pathlib import Path @@ -5,7 +6,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from . import db, logs, version -from .api import fs, history, imports, libraries, requests, search, system +from .api import fs, history, imports, libraries, requests, search, series, system logs.install() @@ -13,7 +14,9 @@ logs.install() @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() + watcher = asyncio.create_task(series.run_daily()) yield + watcher.cancel() app = FastAPI(title="wordarr", lifespan=lifespan) @@ -25,6 +28,7 @@ app.include_router(imports.router) app.include_router(fs.router) app.include_router(history.router) app.include_router(system.router) +app.include_router(series.router) @app.get("/api/version") From 148751218364f4377317da046af992a132e76e24 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:17:28 +0100 Subject: [PATCH 2/2] feat(ui): watch a series from its dialog and review new parts --- static/app.js | 112 +++++++++++++++++++++++++++++++++++++++++++++- static/i18n.js | 26 +++++++++++ static/index.html | 12 +++++ static/style.css | 4 ++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/static/app.js b/static/app.js index ead96b9..c346d13 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", "activity", "settings", "system"]; +const VIEWS = ["search", "missing", "imported", "import", "series", "activity", "settings", "system"]; function showView() { const hash = location.hash.slice(1); @@ -103,6 +103,7 @@ function showView() { if (name === "missing" || name === "imported") loadRequests(name); if (name === "activity") loadHistory(); if (name === "system") loadSystem(); + if (name === "series") loadWatches(); if (name === "settings") loadLibraries().catch((err) => toast(err.message, true)); } @@ -320,6 +321,8 @@ function renderSearchResults() { // ---- request a whole series ---- let seriesEpisodes = []; let seriesType = "audiobook"; +let seriesResult = null; +let seriesLanguage = ""; function renderSeriesList() { const from = parseInt($("#series-from").value); @@ -366,6 +369,8 @@ function updateSeriesCount() { async function openSeriesDialog(result, type = "audiobook", language = "") { const dlg = $("#series-dialog"); seriesType = type; + seriesResult = result; + seriesLanguage = language; const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes"); $("#series-title").textContent = result.series || t("series.fallbackTitle"); $("#series-info").innerHTML = @@ -411,6 +416,31 @@ $("#series-none").addEventListener("click", () => { }); $("#series-cancel").addEventListener("click", () => $("#series-dialog").close()); +$("#series-watch").addEventListener("click", async () => { + const libId = $("#series-library").value; + if (!libId) { + toast(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), true); + return; + } + const btn = $("#series-watch"); + btn.disabled = true; + try { + await api("/api/series", { + method: "POST", + body: JSON.stringify({ + media_type: seriesType, library_id: parseInt(libId), + series_id: seriesResult.series_id, source: seriesResult.source || "", + title: seriesResult.series || seriesResult.title, language: seriesLanguage, + }), + }); + toast(t("watch.added")); + } catch (err) { + toast(err.status === 409 ? t("watch.already") : err.message, true); + } finally { + btn.disabled = false; + } +}); + $("#series-submit").addEventListener("click", async () => { const libId = $("#series-library").value; if (!libId) { @@ -701,6 +731,84 @@ function renderHistory() { .join(""); } +// ---- watched series ---- +let watches = []; + +async function loadWatches(path = "/api/series", opts = {}) { + try { + watches = await api(path, opts); + } catch (err) { + toast(err.message, true); + return; + } + renderWatches(); +} + +function renderWatches() { + const n = watches.reduce((sum, w) => sum + w.new.length, 0); + $("#watch-badge").textContent = n; + $("#watch-badge").hidden = n === 0; + const box = $("#watch-list"); + if (!watches.length) { + box.innerHTML = `

${t("watch.empty")}

`; + return; + } + const locale = lang === "de" ? "de-DE" : "en-GB"; + box.innerHTML = watches + .map((w) => { + const checked = w.checked_at ? new Date(w.checked_at).toLocaleString(locale) : "—"; + const parts = w.new + .map((p) => ` + ${p.volume ?? ""} + ${esc(p.title)}${p.year ? ` (${p.year})` : ""} + ${esc(p.authors)} + + + + + `) + .join(""); + return `
+
+ ${esc(w.title)} + ${esc(w.library_name)} · ${esc(w.media_type)} · ${t("watch.checked", { at: checked })} + +
+ ${w.error ? `

${esc(t("watch.error", { error: w.error }))}

` : ""} + ${parts ? `${parts}
` : `

${t("watch.nothingNew")}

`} +
`; + }) + .join(""); +} + +$("#watch-list").addEventListener("click", async (e) => { + const btn = e.target.closest("button"); + if (!btn) return; + try { + if (btn.dataset.part) { + btn.disabled = true; + await api("/api/series/parts", { + method: "POST", + body: JSON.stringify({ ids: [parseInt(btn.dataset.part)], action: btn.dataset.action }), + }); + if (btn.dataset.action === "request") refreshMissingBadge(); + } else if (btn.dataset.unwatch) { + if (!confirm(t("watch.confirmRemove"))) return; + await api(`/api/series/${btn.dataset.unwatch}`, { method: "DELETE" }); + } else return; + } catch (err) { + toast(err.message, true); + } + loadWatches(); +}); + +$("#watch-check").addEventListener("click", async () => { + const btn = $("#watch-check"); + btn.disabled = true; + await loadWatches("/api/series/check", { method: "POST" }); + btn.disabled = false; +}); + // ---- system ---- let healthIssues = []; let logRows = []; @@ -1962,6 +2070,7 @@ function onLangChange() { renderHistory(); renderHealth(); renderLogs(); + renderWatches(); if (libraries.length) loadLibraries(); const editId = $("#lib-form").dataset.editId; editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit(); @@ -1987,5 +2096,6 @@ updateLangButton(); endLibraryEdit(); loadLibraries(); refreshMissingBadge(); +loadWatches(); showVersion(); showView(); diff --git a/static/i18n.js b/static/i18n.js index 98eaf4a..7a62c18 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -47,6 +47,19 @@ const STRINGS = { "nav.import": "Import", "nav.settings": "Libraries", "nav.activity": "Aktivität", + "nav.series": "Serien", + "watch.button": "Beobachten", + "watch.buttonTitle": "Täglich nach neuen Teilen dieser Serie schauen", + "watch.added": "Serie wird beobachtet", + "watch.already": "Diese Serie wird schon beobachtet", + "watch.check": "Jetzt prüfen", + "watch.empty": "Noch keine Serie beobachtet. In der Suche unter „Ganze Serie…“ auf „Beobachten“ klicken.", + "watch.checked": "geprüft {at}", + "watch.error": "Letzte Prüfung fehlgeschlagen: {error}", + "watch.nothingNew": "Nichts Neues.", + "watch.ignore": "Ignorieren", + "watch.remove": "Nicht mehr beobachten", + "watch.confirmRemove": "Diese Serie nicht mehr beobachten?", "nav.system": "System", "system.health": "Zustand", "system.healthy": "Alles in Ordnung.", @@ -309,6 +322,19 @@ const STRINGS = { "nav.import": "Import", "nav.settings": "Libraries", "nav.activity": "Activity", + "nav.series": "Series", + "watch.button": "Watch", + "watch.buttonTitle": "Look for new parts of this series every day", + "watch.added": "Series is being watched", + "watch.already": "This series is already watched", + "watch.check": "Check now", + "watch.empty": "No series watched yet. In the search, open “Whole series…” and click “Watch”.", + "watch.checked": "checked {at}", + "watch.error": "Last check failed: {error}", + "watch.nothingNew": "Nothing new.", + "watch.ignore": "Ignore", + "watch.remove": "Stop watching", + "watch.confirmRemove": "Stop watching this series?", "nav.system": "System", "system.health": "Health", "system.healthy": "All good.", diff --git a/static/index.html b/static/index.html index 0209978..3fa76ff 100644 --- a/static/index.html +++ b/static/index.html @@ -20,6 +20,10 @@ + + @@ -287,6 +292,13 @@ + +