//! 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 { Router::new() .route("/mangas/:manga_id/chapters", get(list).post(create)) .route("/mangas/:manga_id/chapters/:number", get(get_one)) .route( "/mangas/:manga_id/chapters/:number/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, Path(manga_id): Path, Query(params): Query, ) -> AppResult>> { 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, Path((manga_id, number)): Path<(Uuid, i32)>, ) -> AppResult> { repo::manga::get(&state.db, manga_id).await?; let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number) .await? .ok_or(AppError::NotFound)?; Ok(Json(chapter)) } async fn create( State(state): State, CurrentUser(_user): CurrentUser, Path(manga_id): Path, mut multipart: Multipart, ) -> AppResult<(StatusCode, Json)> { repo::manga::get(&state.db, manga_id).await?; let mut metadata: Option = None; let mut pages: Vec = 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(), ) .await?; 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?; repo::page::create(&mut *tx, chapter.id, page_number, &key, page.mime).await?; } let page_count = pages.len() as i32; repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?; chapter.page_count = page_count; tx.commit().await?; Ok((StatusCode::CREATED, Json(chapter))) } #[derive(Debug, serde::Serialize)] struct PagesResponse { pages: Vec, } async fn list_pages( State(state): State, Path((manga_id, number)): Path<(Uuid, i32)>, ) -> AppResult> { repo::manga::get(&state.db, manga_id).await?; let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number) .await? .ok_or(AppError::NotFound)?; let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?; Ok(Json(PagesResponse { pages })) }