feat(covers): keep a local copy of request covers
This commit is contained in:
1 parent
1487512183
commit
98c7214140
3 files changed
+90
-1
No files matched your search
@@ -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
|
||||
+11
-1
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user