From 98c721414058fd28a7bd07641bebeb7d4f5181f0 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:32:34 +0100 Subject: [PATCH 1/2] feat(covers): keep a local copy of request covers --- tests/test_import_flow.py | 35 +++++++++++++++++++++++++++++++ wordarr/api/requests.py | 12 ++++++++++- wordarr/covers.py | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 wordarr/covers.py diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index abf89c8..9edb5f8 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1424,3 +1424,38 @@ def test_watched_series_suggests_only_new_parts(client, monkeypatch): assert client.delete(f"/api/series/{w['id']}").status_code == 200 assert client.get("/api/series").json() == [] + + +def test_covers_are_fetched_once_and_kept(client, monkeypatch): + import httpx + calls = [] + + async def fake_get(self, url, **kwargs): + calls.append(url) + if "dead" in url: + raise httpx.ConnectError("gone") + ctype = "text/html" if "page" in url else "image/png" + return httpx.Response(200, headers={"content-type": ctype}, content=b"PNG", + request=httpx.Request("GET", url)) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + from wordarr import config + monkeypatch.setattr(config, "CONFIG_DIR", client.tmp_path / "config") + lib = client.post("/api/libraries", json={ + "name": "Covers", "media_type": "ebook", "root_path": str(client.tmp_path)}).json() + + def request(cover_url): + return client.post("/api/requests?allow_duplicate=true", json={ + "library_id": lib["id"], "title": "Buch", "cover_url": cover_url}).json()["id"] + + good = request("https://covers.example/a.png") + for _ in range(2): + resp = client.get(f"/api/requests/{good}/cover") + assert resp.status_code == 200 + assert resp.content == b"PNG" + assert resp.headers["content-type"] == "image/png" + assert calls == ["https://covers.example/a.png"] # the second came from disk + assert [p.suffix for p in (client.tmp_path / "config" / "covers").iterdir()] == [".png"] + + for url in ("https://covers.example/page", "https://covers.example/dead", "", "file:///etc/passwd"): + assert client.get(f"/api/requests/{request(url)}/cover").status_code == 404 diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index 093026f..ec63442 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -1,11 +1,12 @@ from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import FileResponse from pydantic import BaseModel, ConfigDict from sqlalchemy import func, select from sqlalchemy.orm import Session -from .. import config, connect +from .. import config, connect, covers from ..db import BookRequest, Library, get_session, record from ..importer import mover, tagger @@ -237,6 +238,15 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends( return _out(req) +@router.get("/{request_id}/cover") +async def request_cover(request_id: int, session: Session = Depends(get_session)): + req = session.get(BookRequest, request_id) + if not req or not (path := await covers.cached(req.cover_url)): + raise HTTPException(404, "no cover") + # the UI puts the cover url into the query, so a changed cover is a new url + return FileResponse(path, headers={"Cache-Control": "max-age=31536000, immutable"}) + + @router.post("/{request_id}/retag") def retag_request(request_id: int, background: BackgroundTasks, session: Session = Depends(get_session)): diff --git a/wordarr/covers.py b/wordarr/covers.py new file mode 100644 index 0000000..6bd5132 --- /dev/null +++ b/wordarr/covers.py @@ -0,0 +1,44 @@ +"""Covers are fetched once and kept under the config dir, so a dead link or a +slow host does not take the picture with it.""" +import hashlib +import logging +import mimetypes +import tempfile +from pathlib import Path + +import httpx + +from . import config + +MAX_BYTES = 5 * 1024 * 1024 +log = logging.getLogger(__name__) + + +# shortcut: covers of deleted requests stay on disk, add a sweep if the dir grows +async def cached(url: str) -> Path | None: + """The file for this url, downloaded on first use; None if it cannot be had.""" + if not url.startswith(("http://", "https://")): + return None + folder = config.CONFIG_DIR / "covers" + name = hashlib.sha256(url.encode()).hexdigest() + if hit := next(folder.glob(name + ".*"), None): + return hit + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url) + resp.raise_for_status() + except httpx.HTTPError as exc: + log.warning("cover %s failed: %s", url, exc) + return None + ctype = resp.headers.get("content-type", "").split(";")[0].strip() + if not ctype.startswith("image/") or len(resp.content) > MAX_BYTES: + log.warning("cover %s is no usable image (%s, %d bytes)", url, ctype, len(resp.content)) + return None + folder.mkdir(parents=True, exist_ok=True) + # written aside and renamed, so a parallel request never serves half a file + with tempfile.NamedTemporaryFile(dir=folder, suffix=".tmp", delete=False) as tmp: + tmp.write(resp.content) + path = folder / (name + (mimetypes.guess_extension(ctype) or ".img")) + Path(tmp.name).chmod(0o644) # NamedTemporaryFile creates 0600 + Path(tmp.name).replace(path) + return path From 173e24a36240670ec9c5ff7b1717c50e79f2e44e Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:32:48 +0100 Subject: [PATCH 2/2] feat(ui): load request covers through the local cache --- static/app.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/static/app.js b/static/app.js index c346d13..ecc2287 100644 --- a/static/app.js +++ b/static/app.js @@ -81,6 +81,11 @@ function libOptions(mediaType) { .join(""); } +// the server keeps a copy; the url in the query makes a changed cover a new address +function coverSrc(r) { + return `/api/requests/${r.id}/cover?u=${encodeURIComponent(r.cover_url)}`; +} + // ---- missing badge ---- async function refreshMissingBadge() { try { @@ -609,7 +614,7 @@ function renderRequests(status) { box.innerHTML = reqs .map( (r) => `
- ${r.cover_url ? `` : '
?
'} + ${r.cover_url ? `` : '
?
'}
${esc(r.title)}${r.volume != null ? ` ยท ${t("common.volume")} ${r.volume}` : ""} ${esc(r.authors)} @@ -888,7 +893,7 @@ function openDetail(id, status) { const img = $("#detail-cover"); img.hidden = !r.cover_url; $("#detail-nocover").hidden = !!r.cover_url; - if (r.cover_url) img.src = r.cover_url; + if (r.cover_url) img.src = coverSrc(r); $("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook"); $("#detail-relocate").hidden = r.status !== "imported"; $("#detail-dialog").showModal();