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