feat: serve width-bounded thumbnail variants for image grids

Add /files/{key}?w=<px>: the endpoint decodes JPEG/PNG sources, downscales to
a width snapped to a small allow-list (aspect-preserving, never upscaling),
caches the result under a thumbs/ prefix, and serves it — so cover grids
download ~KB instead of the 1–5 MB original. Cover handlers purge cached
variants when a cover (whose key is reused) is replaced or deleted. Frontend
grids use new thumbUrl/thumbSrcset helpers (MangaCard with responsive srcset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:41:19 +02:00
parent 5b1ce581f3
commit 755417730f
13 changed files with 380 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
mod common;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
@@ -45,6 +46,65 @@ fn cover_form(bytes: &[u8]) -> MultipartBuilder {
MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes)
}
/// A real, decodable PNG (unlike `fake_png_bytes`, which is only magic bytes)
/// so the thumbnail endpoint has something to resize.
fn real_png(width: u32, height: u32) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
image::RgbImage::from_pixel(width, height, image::Rgb([10, 120, 200]))
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[sqlx::test(migrations = "./migrations")]
async fn files_serves_downscaled_thumbnail_variant(pool: PgPool) {
let h = harness(pool);
let (_, cookie) = register_user(&h.app).await;
let manga = create_manga_with_cover(&h.app, &cookie, "Thumb", None).await;
let id = id_of(&manga);
// Upload a real 800x400 PNG cover.
let resp = h
.app
.clone()
.oneshot(put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
cover_form(&real_png(800, 400)),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let key = format!("mangas/{id}/cover.png");
// ?w=320 serves a 320px-wide variant with the source content-type.
let resp = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{key}?w=320")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").unwrap().to_str().unwrap(),
"image/png"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let thumb = image::load_from_memory(&body).unwrap();
assert_eq!(thumb.width(), 320, "served a 320px-wide variant");
assert_eq!(thumb.height(), 160, "aspect ratio preserved");
// The un-parametrised request still serves the full-resolution original.
let resp = h
.app
.clone()
.oneshot(get(&format!("/api/v1/files/{key}")))
.await
.unwrap();
let full = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(image::load_from_memory(&full).unwrap().width(), 800);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_sets_path_when_none_existed(pool: PgPool) {
let h = harness(pool);