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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user