From 98c721414058fd28a7bd07641bebeb7d4f5181f0 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Sun, 25 Oct 2026 20:32:34 +0100 Subject: [PATCH] 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