Files
Mangalord/backend/src/api/chapters.rs
MechaCat02 51346227dd feat: route reader by chapter id, allow duplicate-numbered chapters (0.24.0)
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>
2026-05-22 23:37:07 +02:00

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 }))
}