feat(storage): admin storage-usage stats + per-manga/chapter sizes
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled

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:
MechaCat02
2026-06-15 20:14:49 +02:00
parent 4b6c19979a
commit 790549636f
35 changed files with 1804 additions and 40 deletions

View File

@@ -291,7 +291,15 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult<MangaDetail> {
let genres = repo::genre::list_for_manga(pool, id).await?;
let tags = repo::tag::list_for_manga(pool, id).await?;
let content_warnings = repo::page_analysis::warnings_for_manga(pool, id).await?;
Ok(MangaDetail { manga, authors, genres, tags, content_warnings })
let chapter_storage_bytes = repo::storage_stats::manga_chapter_bytes(pool, id).await?;
Ok(MangaDetail {
manga,
authors,
genres,
tags,
content_warnings,
chapter_storage_bytes,
})
}
/// Insert just the manga row. Relations (authors, genres) are written
@@ -372,12 +380,17 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>(
executor: E,
id: Uuid,
key: &str,
size_bytes: i64,
) -> AppResult<()> {
sqlx::query("UPDATE mangas SET cover_image_path = $1, updated_at = now() WHERE id = $2")
.bind(key)
.bind(id)
.execute(executor)
.await?;
sqlx::query(
"UPDATE mangas SET cover_image_path = $1, cover_size_bytes = $2, updated_at = now() \
WHERE id = $3",
)
.bind(key)
.bind(size_bytes)
.bind(id)
.execute(executor)
.await?;
Ok(())
}
@@ -385,10 +398,13 @@ pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>(
executor: E,
id: Uuid,
) -> AppResult<()> {
sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
sqlx::query(
"UPDATE mangas SET cover_image_path = NULL, cover_size_bytes = NULL, updated_at = now() \
WHERE id = $1",
)
.bind(id)
.execute(executor)
.await?;
Ok(())
}