feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage usage to admins in two places: - Admin System tab: total/covers/chapters totals, ratio of disk, average image sizes, and top-5 largest mangas/chapters leaderboards, behind a new GET /api/v1/admin/storage endpoint (separate from /admin/system so the cheap DB aggregates aren't coupled to its 250ms CPU sample). - Manga detail page: total chapter-content size and per-chapter size, with an em-dash for uncrawled or not-yet-measured chapters. Sizes are captured at write time (crawler put_stream return, uploaded page/cover byte length) into nullable pages.size_bytes / mangas.cover_size_bytes columns; NULL means "not yet measured", distinct from a real 0. A one-shot, idempotent admin "Backfill sizes" action (POST /api/v1/admin/storage/backfill) stats pre-existing blobs in keyset-paginated, capped batches and reports more_remaining so a large legacy library can be drained over multiple runs. Aggregates and leaderboards treat NULL honestly (only fully-measured entities are ranked); a dashboard banner flags unmeasured rows so partial figures aren't read as complete. persist_pages now prunes stale page rows on a shrinking re-crawl so totals and page_count stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,11 @@ async fn create_chapter_with_pages_stores_each(pool: PgPool) {
|
||||
assert_eq!(body["number"], 1);
|
||||
assert_eq!(body["title"], "The Brand");
|
||||
assert_eq!(body["page_count"], 3);
|
||||
// The 201 body reflects the just-uploaded page bytes, not the stale 0
|
||||
// the chapter row carried before its pages were inserted.
|
||||
let created_total =
|
||||
(common::fake_png_bytes().len() * 2 + common::fake_jpeg_bytes().len()) as i64;
|
||||
assert_eq!(body["size_bytes"].as_i64().unwrap(), created_total);
|
||||
|
||||
let chapter_id = Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
|
||||
|
||||
@@ -244,6 +249,32 @@ async fn create_chapter_with_pages_stores_each(pool: PgPool) {
|
||||
.unwrap();
|
||||
assert_eq!(ct, expected_ct);
|
||||
}
|
||||
|
||||
// The chapter list reports per-chapter storage = sum of the page
|
||||
// bytes, captured at upload time (all pages measured → a number, not
|
||||
// null).
|
||||
let expected = (common::fake_png_bytes().len() * 2 + common::fake_jpeg_bytes().len()) as i64;
|
||||
let list_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters")))
|
||||
.await
|
||||
.unwrap();
|
||||
let list = common::body_json(list_resp).await;
|
||||
let ch = &list["items"][0];
|
||||
assert_eq!(ch["id"].as_str().unwrap(), chapter_id.to_string());
|
||||
assert_eq!(ch["size_bytes"].as_i64().unwrap(), expected);
|
||||
|
||||
// The manga detail rolls those page bytes up into chapter_storage_bytes
|
||||
// (cover excluded), measured at upload time so it's a number, not null.
|
||||
let detail_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
|
||||
.await
|
||||
.unwrap();
|
||||
let detail = common::body_json(detail_resp).await;
|
||||
assert_eq!(detail["chapter_storage_bytes"].as_i64().unwrap(), expected);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
|
||||
Reference in New Issue
Block a user