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>
205 lines
7.0 KiB
Rust
205 lines
7.0 KiB
Rust
//! Chapter list + get + multipart upload.
|
|
//!
|
|
//! Reads are public. Uploads (POST) require auth and use the same
|
|
//! multipart conventions as `POST /api/v1/mangas`:
|
|
//! - `metadata` part (JSON) with `{ number, title? }`.
|
|
//! - One or more `page` parts (images, ordered by arrival).
|
|
|
|
use axum::extract::{Multipart, Path, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::api::mangas::{next_field, read_field_bytes};
|
|
use crate::api::pagination::PagedResponse;
|
|
use crate::app::AppState;
|
|
use crate::auth::extractor::CurrentUser;
|
|
use crate::domain::chapter::NewChapter;
|
|
use crate::domain::{Chapter, Page};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::repo;
|
|
use crate::upload::{parse_image, UploadedImage};
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
|
.route("/mangas/:manga_id/chapters/:chapter_id", get(get_one))
|
|
.route(
|
|
"/mangas/:manga_id/chapters/:chapter_id/pages",
|
|
get(list_pages),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListParams {
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
#[serde(default)]
|
|
pub offset: i64,
|
|
}
|
|
|
|
fn default_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
async fn list(
|
|
State(state): State<AppState>,
|
|
Path(manga_id): Path<Uuid>,
|
|
Query(params): Query<ListParams>,
|
|
) -> AppResult<Json<PagedResponse<Chapter>>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
|
|
let limit = params.limit.clamp(1, 200);
|
|
let offset = params.offset.max(0);
|
|
let items = repo::chapter::list_for_manga(&state.db, manga_id, limit, offset).await?;
|
|
Ok(Json(PagedResponse::new(items, limit, offset)))
|
|
}
|
|
|
|
async fn get_one(
|
|
State(state): State<AppState>,
|
|
Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
|
|
) -> AppResult<Json<Chapter>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
Ok(Json(chapter))
|
|
}
|
|
|
|
async fn create(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(manga_id): Path<Uuid>,
|
|
mut multipart: Multipart,
|
|
) -> AppResult<(StatusCode, Json<Chapter>)> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
|
|
let mut metadata: Option<NewChapter> = None;
|
|
let mut pages: Vec<UploadedImage> = Vec::new();
|
|
|
|
while let Some(field) = next_field(&mut multipart).await? {
|
|
match field.name() {
|
|
Some("metadata") => {
|
|
let bytes = read_field_bytes(field).await?;
|
|
metadata =
|
|
Some(serde_json::from_slice(&bytes).map_err(|e| {
|
|
AppError::ValidationFailed {
|
|
message: "metadata is not valid JSON".into(),
|
|
details: json!({ "metadata": e.to_string() }),
|
|
}
|
|
})?);
|
|
}
|
|
Some("page") => {
|
|
let bytes = read_field_bytes(field).await?.to_vec();
|
|
let field_name = format!("page[{}]", pages.len());
|
|
pages.push(parse_image(bytes, state.upload.max_file_bytes, &field_name)?);
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
let metadata = metadata.ok_or_else(|| AppError::ValidationFailed {
|
|
message: "metadata part is required".into(),
|
|
details: json!({ "metadata": "required" }),
|
|
})?;
|
|
// Chapter number is 1-indexed everywhere (URLs, upload form,
|
|
// reader). Reject 0 / negative numbers up front so the row never
|
|
// makes it into the DB. Mirrors the page>=1 rule on bookmarks.
|
|
if metadata.number < 1 {
|
|
return Err(AppError::ValidationFailed {
|
|
message: "chapter number must be 1 or greater".into(),
|
|
details: json!({ "number": "must be >= 1" }),
|
|
});
|
|
}
|
|
if pages.is_empty() {
|
|
return Err(AppError::ValidationFailed {
|
|
message: "at least one page is required".into(),
|
|
details: json!({ "page": "at least one required" }),
|
|
});
|
|
}
|
|
|
|
// Transactional create. If any storage put or page-row insert
|
|
// fails mid-loop, the chapter row + any earlier page rows are
|
|
// rolled back so we don't leave a chapter with stale page_count=0
|
|
// and orphaned page rows. Bytes already written to storage on a
|
|
// rolled-back transaction become orphans on disk; a future reaper
|
|
// can sweep them. DB consistency wins over storage tidiness here.
|
|
let mut tx = state.db.begin().await?;
|
|
let mut chapter = repo::chapter::create(
|
|
&mut *tx,
|
|
manga_id,
|
|
metadata.number,
|
|
metadata.title.as_deref(),
|
|
Some(user.id),
|
|
)
|
|
.await?;
|
|
|
|
let mut page_ids: Vec<Uuid> = Vec::with_capacity(pages.len());
|
|
for (idx, page) in pages.iter().enumerate() {
|
|
let page_number = (idx + 1) as i32;
|
|
let nnnn = format!("{:04}", page_number);
|
|
let key = format!(
|
|
"mangas/{}/chapters/{}/pages/{}.{}",
|
|
manga_id, chapter.id, nnnn, page.ext
|
|
);
|
|
state.storage.put(&key, &page.bytes).await?;
|
|
let created = repo::page::create(
|
|
&mut *tx,
|
|
chapter.id,
|
|
page_number,
|
|
&key,
|
|
page.mime,
|
|
page.bytes.len() as i64,
|
|
)
|
|
.await?;
|
|
page_ids.push(created.id);
|
|
}
|
|
|
|
let page_count = pages.len() as i32;
|
|
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
|
|
chapter.page_count = page_count;
|
|
// `repo::chapter::create` returned the row before any pages existed, so
|
|
// its `size_bytes` is a stale 0. Every uploaded page's size was just
|
|
// captured, so the true total is the sum of their byte lengths — set it
|
|
// on the response so the 201 body matches the persisted state.
|
|
chapter.size_bytes = Some(pages.iter().map(|p| p.bytes.len() as i64).sum());
|
|
|
|
tx.commit().await?;
|
|
|
|
// Enqueue AI content-analysis for each new page. Done after commit so a
|
|
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
|
|
// failed enqueue is logged but doesn't fail the upload (the admin
|
|
// re-enqueue endpoint can backfill).
|
|
if state.analysis_enabled() {
|
|
for page_id in page_ids {
|
|
if let Err(e) =
|
|
repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await
|
|
{
|
|
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((StatusCode::CREATED, Json(chapter)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct PagesResponse {
|
|
pages: Vec<Page>,
|
|
}
|
|
|
|
async fn list_pages(
|
|
State(state): State<AppState>,
|
|
Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
|
|
) -> AppResult<Json<PagesResponse>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
|
|
Ok(Json(PagesResponse { pages }))
|
|
}
|