Files
Mangalord/backend/src/repo/upload_history.rs
MechaCat02 790549636f
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
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>
2026-06-15 20:18:41 +02:00

128 lines
4.0 KiB
Rust

//! Cross-table upload history.
//!
//! Mangas and chapters are uploaded by users separately, but the
//! profile UI wants a single chronological feed. Rather than open a
//! UNION-ALL over two tables with mismatched columns we fetch each
//! side, then merge in Rust by `created_at`. Cheap for the volumes a
//! single user produces.
//!
//! Pagination uses limit-only for now; offsets across two unrelated
//! tables aren't trivially stable, and the realistic per-user upload
//! count is small. Switch to keyset pagination if real users blow
//! past a few hundred uploads.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::chapter::Chapter;
use crate::domain::manga::Manga;
use crate::domain::upload_entry::UploadEntry;
use crate::error::AppResult;
#[derive(sqlx::FromRow)]
struct ChapterUploadRow {
manga_id: Uuid,
manga_title: String,
manga_cover_image_path: Option<String>,
chapter_id: Uuid,
number: i32,
title: Option<String>,
page_count: i32,
created_at: chrono::DateTime<chrono::Utc>,
size_bytes: Option<i64>,
}
/// Returns up to `limit` of the user's most recent uploads (mangas and
/// chapters interleaved by `created_at DESC`) plus the unfiltered
/// total count (mangas + chapters owned by the user). The caller is
/// responsible for clamping `limit` to a sane value.
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
) -> AppResult<(Vec<UploadEntry>, i64)> {
let mangas: Vec<Manga> = sqlx::query_as::<_, Manga>(
r#"
SELECT id, title, status, alt_titles, description,
cover_image_path, created_at, updated_at
FROM mangas
WHERE uploaded_by = $1
ORDER BY created_at DESC, id
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
let chapters: Vec<ChapterUploadRow> = sqlx::query_as::<_, ChapterUploadRow>(
r#"
SELECT c.manga_id,
m.title AS manga_title,
m.cover_image_path AS manga_cover_image_path,
c.id AS chapter_id,
c.number,
c.title,
c.page_count,
c.created_at,
(SELECT CASE
WHEN count(*) = 0 THEN 0
WHEN bool_or(p.size_bytes IS NULL) THEN NULL
ELSE sum(p.size_bytes)
END
FROM pages p WHERE p.chapter_id = c.id)::bigint AS size_bytes
FROM chapters c
JOIN mangas m ON m.id = c.manga_id
WHERE c.uploaded_by = $1
ORDER BY c.created_at DESC, c.id
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
let mut entries: Vec<UploadEntry> = Vec::with_capacity(mangas.len() + chapters.len());
for m in mangas {
entries.push(UploadEntry::Manga {
created_at: m.created_at,
manga: m,
});
}
for c in chapters {
let created_at = c.created_at;
entries.push(UploadEntry::Chapter {
manga_id: c.manga_id,
manga_title: c.manga_title,
manga_cover_image_path: c.manga_cover_image_path,
chapter: Chapter {
id: c.chapter_id,
manga_id: c.manga_id,
number: c.number,
title: c.title,
page_count: c.page_count,
created_at: c.created_at,
size_bytes: c.size_bytes,
},
created_at,
});
}
// Newest first; trim to limit after the merge.
entries.sort_by(|a, b| b.created_at().cmp(&a.created_at()));
entries.truncate(limit as usize);
let (manga_total, chapter_total): (i64, i64) = sqlx::query_as(
r#"
SELECT
(SELECT count(*) FROM mangas WHERE uploaded_by = $1),
(SELECT count(*) FROM chapters WHERE uploaded_by = $1)
"#,
)
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((entries, manga_total + chapter_total))
}