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

@@ -13,6 +13,7 @@
} from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { formatBytes } from '$lib/upload-validation';
import { listTags, type Tag } from '$lib/api/tags';
import type { ContentWarning } from '$lib/api/page_tags';
import { session } from '$lib/session.svelte';
@@ -39,6 +40,12 @@
const manga = $derived<MangaDetail>(mangaOverride ?? data.manga);
const chapters = $derived(data.chapters);
const readProgress = $derived(data.readProgress);
/** Total stored bytes of this manga's chapter content (cover excluded).
* Comes from the backend, which sums over ALL chapters (the loaded
* chapter list is paginated, so summing it client-side would
* undercount mangas with many chapters). `null` → unknown (a page is
* un-backfilled) → show an em-dash; `0` → no content → hide. */
const contentBytes = $derived(manga.chapter_storage_bytes);
/** Chapter row from the local chapters list when present (so we
* can also surface the chapter title). Falls back below to the
* server-supplied `chapter_number` when the chapter sits past
@@ -589,7 +596,14 @@
{/if}
<section aria-label="chapters">
<h2>Chapters</h2>
<div class="chapters-head">
<h2>Chapters</h2>
{#if contentBytes === null || contentBytes > 0}
<span class="content-size" data-testid="manga-content-size">
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
</span>
{/if}
</div>
{#if continueChapterId != null && continueChapterNumber != null}
<a
class="continue"
@@ -615,6 +629,11 @@
{chapterLabel(c)}
</a>
<span class="pages">({c.page_count} pages)</span>
<span class="size" data-testid="chapter-size">
{c.page_count === 0 || c.size_bytes === null
? '—'
: formatBytes(c.size_bytes)}
</span>
</li>
{/each}
</ol>
@@ -996,6 +1015,26 @@
font-size: var(--font-sm);
}
.chapters-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.content-size {
color: var(--text-muted);
font-size: var(--font-sm);
font-family: var(--font-mono, monospace);
}
.size {
color: var(--text-muted);
margin-left: var(--space-2);
font-size: var(--font-sm);
font-family: var(--font-mono, monospace);
}
/* Mobile hero + chrome — hidden above 640px so the existing
desktop layout stays untouched. */
.mobile-hero {