//! Cross-cutting storage-usage roll-ups for the admin dashboard, plus //! the one-shot size backfill helpers. These join `pages` → `chapters` //! → `mangas`, so they live here rather than on any single-resource repo //! module (mirrors `repo::admin_view`). Every figure is a single indexed //! aggregate; the `pages_chapter_size_idx` (migration 0027) keeps the //! per-chapter / per-manga roll-ups cheap. //! //! `size_bytes` / `cover_size_bytes` are nullable: NULL means "not yet //! measured". SUM/AVG ignore NULL, so an un-backfilled library reports //! honest partial totals; the backfill fills the NULLs. use sqlx::PgPool; use uuid::Uuid; use crate::domain::{TopChapter, TopManga}; use crate::error::AppResult; /// Max rows stat-ed + updated per backfill batch. Bounds memory and /// keeps each UPDATE statement's array parameters reasonable. pub const BACKFILL_BATCH: i64 = 500; /// `(covers_bytes, chapters_bytes)` — total measured bytes split by kind. /// Unmeasured (NULL) rows contribute nothing. pub async fn totals(pool: &PgPool) -> AppResult<(i64, i64)> { let (covers,): (i64,) = sqlx::query_as("SELECT COALESCE(SUM(cover_size_bytes), 0)::bigint FROM mangas") .fetch_one(pool) .await?; let (chapters,): (i64,) = sqlx::query_as("SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM pages") .fetch_one(pool) .await?; Ok((covers, chapters)) } /// `(avg_all, avg_cover, avg_chapter_page)` average image sizes. Each is /// `None` when its population has no measured rows (no division by zero). /// `AVG` ignores NULL, so unmeasured blobs are naturally excluded. pub async fn averages( pool: &PgPool, ) -> AppResult<(Option, Option, Option)> { let (avg_chapter,): (Option,) = sqlx::query_as("SELECT AVG(size_bytes)::float8 FROM pages") .fetch_one(pool) .await?; let (avg_cover,): (Option,) = sqlx::query_as("SELECT AVG(cover_size_bytes)::float8 FROM mangas") .fetch_one(pool) .await?; let (avg_all,): (Option,) = sqlx::query_as( "SELECT AVG(s)::float8 FROM ( SELECT size_bytes AS s FROM pages WHERE size_bytes IS NOT NULL UNION ALL SELECT cover_size_bytes FROM mangas WHERE cover_size_bytes IS NOT NULL ) x", ) .fetch_one(pool) .await?; Ok((avg_all, avg_cover, avg_chapter)) } /// Largest mangas by cover + summed chapter-page bytes. Only *fully /// measured* mangas are ranked — a manga with any unmeasured page (or an /// unmeasured cover) is excluded rather than ranked by an understated /// partial total, so the board never disagrees with the per-manga /// "unknown" the detail page shows. (Pre-backfill the board is empty; the /// dashboard's unmeasured-count banner explains why.) pub async fn top_mangas(pool: &PgPool, limit: i64) -> AppResult> { let rows: Vec<(Uuid, String, i64)> = sqlx::query_as( "SELECT id, title, total_bytes FROM ( SELECT m.id, m.title, (COALESCE(m.cover_size_bytes, 0) + COALESCE((SELECT SUM(p.size_bytes) FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE c.manga_id = m.id), 0))::bigint AS total_bytes, ((m.cover_image_path IS NULL OR m.cover_size_bytes IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE c.manga_id = m.id AND p.size_bytes IS NULL)) AS complete FROM mangas m ) t WHERE total_bytes > 0 AND complete ORDER BY total_bytes DESC, id LIMIT $1", ) .bind(limit) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(id, title, total_bytes)| TopManga { id, title, total_bytes }) .collect()) } /// Largest chapters by summed stored page bytes. Only *fully measured* /// chapters are ranked: INNER JOIN drops page-less chapters, and the /// `bool_or(... IS NULL)` guard drops any chapter with an unmeasured page /// so the board matches the chapter list's "unknown" (em-dash) instead of /// ranking by an understated partial. pub async fn top_chapters(pool: &PgPool, limit: i64) -> AppResult> { let rows: Vec<(Uuid, Uuid, i32, Option, i64)> = sqlx::query_as( "SELECT c.id, c.manga_id, c.number, c.title, SUM(p.size_bytes)::bigint AS total_bytes FROM chapters c JOIN pages p ON p.chapter_id = c.id GROUP BY c.id HAVING bool_or(p.size_bytes IS NULL) IS NOT TRUE AND SUM(p.size_bytes) > 0 ORDER BY total_bytes DESC, c.id LIMIT $1", ) .bind(limit) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(id, manga_id, number, title, total_bytes)| TopChapter { id, manga_id, number, title, total_bytes, }) .collect()) } /// Total bytes of a single manga's chapter pages (cover excluded), summed /// over **all** its chapters. `None` when any of the manga's pages is /// unmeasured (so the UI shows "unknown" rather than a silent undercount); /// `Some(0)` when the manga has no pages. This is computed in the DB over /// the full page set — it can't be derived correctly from a paginated /// chapter list — so `MangaDetail` carries it even though it adds one /// indexed aggregate to the (un-polled) detail read. pub async fn manga_chapter_bytes(pool: &PgPool, manga_id: Uuid) -> AppResult> { let (total,): (Option,) = sqlx::query_as( "SELECT CASE WHEN count(*) = 0 THEN 0 WHEN bool_or(p.size_bytes IS NULL) THEN NULL ELSE sum(p.size_bytes) END::bigint FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE c.manga_id = $1", ) .bind(manga_id) .fetch_one(pool) .await?; Ok(total) } /// `(unmeasured_pages, unmeasured_covers)` — counts of rows still awaiting /// a backfill. Drives the dashboard "N items not yet measured" banner and /// signals whether another backfill run is needed. pub async fn unmeasured_counts(pool: &PgPool) -> AppResult<(i64, i64)> { let (pages,): (i64,) = sqlx::query_as("SELECT count(*)::bigint FROM pages WHERE size_bytes IS NULL") .fetch_one(pool) .await?; let (covers,): (i64,) = sqlx::query_as( "SELECT count(*)::bigint FROM mangas \ WHERE cover_size_bytes IS NULL AND cover_image_path IS NOT NULL", ) .fetch_one(pool) .await?; Ok((pages, covers)) } // --- backfill --------------------------------------------------------- // // Keyset-paginated so the whole un-backfilled set is never materialized // at once and the loop terminates even when some blobs can't be stat-ed // (those rows stay NULL but `id > after` advances past them). The UPDATEs // are batched via UNNEST and guarded on `IS NULL` so a concurrent write // of a real size between the scan and the update is never clobbered. /// Up to `limit` pages with an unmeasured size, ordered by id after /// `after`. Pass `Uuid::nil()` to start. pub async fn pages_needing_backfill_after( pool: &PgPool, after: Uuid, limit: i64, ) -> AppResult> { let rows: Vec<(Uuid, String)> = sqlx::query_as( "SELECT id, storage_key FROM pages \ WHERE size_bytes IS NULL AND id > $1 ORDER BY id LIMIT $2", ) .bind(after) .bind(limit) .fetch_all(pool) .await?; Ok(rows) } /// Mangas with a cover blob but no measured cover size, ordered by id /// after `after`. pub async fn covers_needing_backfill_after( pool: &PgPool, after: Uuid, limit: i64, ) -> AppResult> { let rows: Vec<(Uuid, String)> = sqlx::query_as( "SELECT id, cover_image_path FROM mangas \ WHERE cover_size_bytes IS NULL AND cover_image_path IS NOT NULL \ AND id > $1 ORDER BY id LIMIT $2", ) .bind(after) .bind(limit) .fetch_all(pool) .await?; Ok(rows) } /// Apply a batch of `(id, size)` updates in one statement and return the /// number of rows actually written. The `IS NULL` guard makes this a /// no-op for any row a concurrent crawl/upload has already measured, /// closing the lost-update window; returning the affected count (rather /// than `ids.len()`) keeps the backfill's reported total honest when that /// happens. pub async fn batch_set_page_sizes( pool: &PgPool, ids: &[Uuid], sizes: &[i64], ) -> AppResult { if ids.is_empty() { return Ok(0); } let result = sqlx::query( "UPDATE pages SET size_bytes = v.s \ FROM unnest($1::uuid[], $2::bigint[]) AS v(id, s) \ WHERE pages.id = v.id AND pages.size_bytes IS NULL", ) .bind(ids) .bind(sizes) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn batch_set_cover_sizes( pool: &PgPool, ids: &[Uuid], sizes: &[i64], ) -> AppResult { if ids.is_empty() { return Ok(0); } let result = sqlx::query( "UPDATE mangas SET cover_size_bytes = v.s \ FROM unnest($1::uuid[], $2::bigint[]) AS v(id, s) \ WHERE mangas.id = v.id AND mangas.cover_size_bytes IS NULL", ) .bind(ids) .bind(sizes) .execute(pool) .await?; Ok(result.rows_affected()) }