Merge pull request 'Feature/cache covers' (#9) from feature/cache-covers into main
Build image / image (push) Successful in 53s

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
admin committed 2026-10-25 20:33:29 +01:00
commit 33d82ab7d4
4 files changed
+97 -3

No files matched your search

+7 -2
View File
@@ -81,6 +81,11 @@ function libOptions(mediaType) {
.join(""); .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 ---- // ---- missing badge ----
async function refreshMissingBadge() { async function refreshMissingBadge() {
try { try {
@@ -609,7 +614,7 @@ function renderRequests(status) {
box.innerHTML = reqs box.innerHTML = reqs
.map( .map(
(r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0"> (r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'} ${r.cover_url ? `<img src="${esc(coverSrc(r))}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
<div class="card-body"> <div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong> <strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
<span>${esc(r.authors)}</span> <span>${esc(r.authors)}</span>
@@ -888,7 +893,7 @@ function openDetail(id, status) {
const img = $("#detail-cover"); const img = $("#detail-cover");
img.hidden = !r.cover_url; img.hidden = !r.cover_url;
$("#detail-nocover").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-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
$("#detail-relocate").hidden = r.status !== "imported"; $("#detail-relocate").hidden = r.status !== "imported";
$("#detail-dialog").showModal(); $("#detail-dialog").showModal();
+35
View File
@@ -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.delete(f"/api/series/{w['id']}").status_code == 200
assert client.get("/api/series").json() == [] 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
+11 -1
View File
@@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config, connect from .. import config, connect, covers
from ..db import BookRequest, Library, get_session, record from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger from ..importer import mover, tagger
@@ -237,6 +238,15 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
return _out(req) 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") @router.post("/{request_id}/retag")
def retag_request(request_id: int, background: BackgroundTasks, def retag_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
+44
View File
@@ -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