Real-world sources publish multiple chapters at the same number:
different scanlators ("Ch.52 from bloomingdale" + "Ch.52 from mina"),
translator notices and farewells, alt-translations. The (manga_id,
number) UNIQUE constraint from 0001 silently collapsed all of those
into a single row via the upsert path in repo::crawler. Migration 0013
drops the constraint; sync_manga_chapters now plain-INSERTs each
SourceChapterRef so every parsed chapter survives as its own row.
Identity moves from the (manga_id, number) tuple to the chapter UUID:
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id` (replaces :number)
- `GET /api/v1/mangas/:manga_id/chapters/:chapter_id/pages`
- `repo::chapter::find_by_id_in_manga` (replaces find_by_manga_and_number)
- Frontend reader route renamed to `/manga/[id]/chapter/[chapter_id]`
- Chapter links throughout (manga page list, continue-reading CTA,
reader prev/next, history rows, bookmark cards) use chapter.id
- API clients getChapter/getChapterPages take a chapter id string
read_progress + bookmarks already FK chapter_id; they only enrich with
chapter_number for display, which is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.9 KiB
Rust
176 lines
5.9 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?;
|
|
|
|
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<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 }))
|
|
}
|