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();
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