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>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1415,7 +1415,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.23.1"
|
version = "0.24.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.23.1"
|
version = "0.24.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
18
backend/migrations/0013_drop_chapters_unique_number.sql
Normal file
18
backend/migrations/0013_drop_chapters_unique_number.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Real-world sources publish multiple chapters at the same number:
|
||||||
|
-- different uploaders, translator notices/farewells, paid-vs-free
|
||||||
|
-- re-uploads, and our own users can legitimately have two versions of
|
||||||
|
-- "Ch.52" with different scanlations. The (manga_id, number) UNIQUE
|
||||||
|
-- from 0001_init silently collapses all of those into a single row via
|
||||||
|
-- ON CONFLICT, dropping data. Drop the constraint and lean on the
|
||||||
|
-- chapter id (UUID) as the only chapter identity going forward.
|
||||||
|
|
||||||
|
ALTER TABLE chapters DROP CONSTRAINT chapters_manga_id_number_key;
|
||||||
|
|
||||||
|
-- The UNIQUE was also our only index on (manga_id, number) since
|
||||||
|
-- 0007 dropped the redundant explicit one. Chapter list pages
|
||||||
|
-- ORDER BY number ASC and the manga page is a hot read path, so put
|
||||||
|
-- the index back without the uniqueness. Secondary sort by created_at
|
||||||
|
-- so duplicate-numbered chapters have a stable order in lists and
|
||||||
|
-- prev/next navigation.
|
||||||
|
CREATE INDEX chapters_manga_id_number_idx
|
||||||
|
ON chapters (manga_id, number, created_at);
|
||||||
@@ -26,9 +26,9 @@ use crate::upload::{parse_image, UploadedImage};
|
|||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
||||||
.route("/mangas/:manga_id/chapters/:number", get(get_one))
|
.route("/mangas/:manga_id/chapters/:chapter_id", get(get_one))
|
||||||
.route(
|
.route(
|
||||||
"/mangas/:manga_id/chapters/:number/pages",
|
"/mangas/:manga_id/chapters/:chapter_id/pages",
|
||||||
get(list_pages),
|
get(list_pages),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -60,10 +60,10 @@ async fn list(
|
|||||||
|
|
||||||
async fn get_one(
|
async fn get_one(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((manga_id, number)): Path<(Uuid, i32)>,
|
Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> AppResult<Json<Chapter>> {
|
) -> AppResult<Json<Chapter>> {
|
||||||
repo::manga::get(&state.db, manga_id).await?;
|
repo::manga::get(&state.db, manga_id).await?;
|
||||||
let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number)
|
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
Ok(Json(chapter))
|
Ok(Json(chapter))
|
||||||
@@ -164,10 +164,10 @@ struct PagesResponse {
|
|||||||
|
|
||||||
async fn list_pages(
|
async fn list_pages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((manga_id, number)): Path<(Uuid, i32)>,
|
Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> AppResult<Json<PagesResponse>> {
|
) -> AppResult<Json<PagesResponse>> {
|
||||||
repo::manga::get(&state.db, manga_id).await?;
|
repo::manga::get(&state.db, manga_id).await?;
|
||||||
let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number)
|
let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
|
let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ pub async fn list_for_manga(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> AppResult<Vec<Chapter>> {
|
) -> AppResult<Vec<Chapter>> {
|
||||||
|
// Secondary sort by created_at gives duplicate-numbered chapters
|
||||||
|
// (multiple uploaders/translations of the same number) a stable
|
||||||
|
// order in lists and prev/next reader navigation.
|
||||||
let rows = sqlx::query_as::<_, Chapter>(
|
let rows = sqlx::query_as::<_, Chapter>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, manga_id, number, title, page_count, created_at
|
SELECT id, manga_id, number, title, page_count, created_at
|
||||||
FROM chapters
|
FROM chapters
|
||||||
WHERE manga_id = $1
|
WHERE manga_id = $1
|
||||||
ORDER BY number ASC
|
ORDER BY number ASC, created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -29,33 +32,40 @@ pub async fn list_for_manga(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_manga_and_number(
|
/// Look up a chapter by its UUID, scoped to its manga so a UUID guessed
|
||||||
|
/// from a different manga's URL doesn't accidentally resolve.
|
||||||
|
pub async fn find_by_id_in_manga(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
number: i32,
|
chapter_id: Uuid,
|
||||||
) -> AppResult<Option<Chapter>> {
|
) -> AppResult<Option<Chapter>> {
|
||||||
let row = sqlx::query_as::<_, Chapter>(
|
let row = sqlx::query_as::<_, Chapter>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, manga_id, number, title, page_count, created_at
|
SELECT id, manga_id, number, title, page_count, created_at
|
||||||
FROM chapters
|
FROM chapters
|
||||||
WHERE manga_id = $1 AND number = $2
|
WHERE manga_id = $1 AND id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(manga_id)
|
.bind(manga_id)
|
||||||
.bind(number)
|
.bind(chapter_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accepts any `PgExecutor` so the upload handler can run this inside a
|
/// Accepts any `PgExecutor` so the upload handler can run this inside a
|
||||||
/// transaction with the per-page inserts. Returns `AppError::Conflict`
|
/// transaction with the per-page inserts.
|
||||||
/// on the (manga_id, number) unique violation so handlers can surface a
|
|
||||||
/// clean 409.
|
|
||||||
///
|
///
|
||||||
/// `uploaded_by` records who uploaded the chapter and feeds the
|
/// `uploaded_by` records who uploaded the chapter and feeds the
|
||||||
/// per-user upload history. `None` means "historical / API token with
|
/// per-user upload history. `None` means "historical / API token with
|
||||||
/// no associated user" — kept nullable to support that case.
|
/// no associated user" — kept nullable to support that case.
|
||||||
|
///
|
||||||
|
/// Chapter identity is the row UUID; the same (manga_id, number)
|
||||||
|
/// combination can repeat (multiple translations, re-uploads). The
|
||||||
|
/// `is_unique_violation` branch below is a defensive holdover from
|
||||||
|
/// 0001's (manga_id, number) UNIQUE — it can no longer fire under
|
||||||
|
/// normal operation, but we surface a clean 409 if a future migration
|
||||||
|
/// re-adds any chapter uniqueness.
|
||||||
pub async fn create<'e, E: PgExecutor<'e>>(
|
pub async fn create<'e, E: PgExecutor<'e>>(
|
||||||
executor: E,
|
executor: E,
|
||||||
manga_id: Uuid,
|
manga_id: Uuid,
|
||||||
@@ -80,7 +90,7 @@ pub async fn create<'e, E: PgExecutor<'e>>(
|
|||||||
match result {
|
match result {
|
||||||
Ok(c) => Ok(c),
|
Ok(c) => Ok(c),
|
||||||
Err(e) if is_unique_violation(&e) => Err(AppError::Conflict(format!(
|
Err(e) if is_unique_violation(&e) => Err(AppError::Conflict(format!(
|
||||||
"chapter {number} already exists for this manga"
|
"chapter {number} conflicts with an existing chapter for this manga"
|
||||||
))),
|
))),
|
||||||
Err(e) => Err(AppError::Database(e)),
|
Err(e) => Err(AppError::Database(e)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,15 +332,15 @@ pub async fn sync_manga_chapters(
|
|||||||
|
|
||||||
match existing {
|
match existing {
|
||||||
None => {
|
None => {
|
||||||
// New chapter row. The (manga_id, number) unique
|
// New chapter row. As of 0013 there's no (manga_id,
|
||||||
// constraint protects against re-inserts if the same
|
// number) UNIQUE, so duplicate-numbered chapters from
|
||||||
// number arrives via a different source_chapter_key.
|
// the source (different uploaders, notices, alt
|
||||||
|
// translations) each get their own row — chapter
|
||||||
|
// identity is the UUID, not the number.
|
||||||
let (chapter_id,): (Uuid,) = sqlx::query_as(
|
let (chapter_id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO chapters (manga_id, number, title, page_count)
|
INSERT INTO chapters (manga_id, number, title, page_count)
|
||||||
VALUES ($1, $2, $3, 0)
|
VALUES ($1, $2, $3, 0)
|
||||||
ON CONFLICT (manga_id, number) DO UPDATE
|
|
||||||
SET title = EXCLUDED.title
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid {
|
|||||||
common::seed_manga_via_api(&h.app, cookie, title).await
|
common::seed_manga_via_api(&h.app, cookie, title).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn seed_chapter(pool: &PgPool, manga_id: Uuid, number: i32, title: Option<&str>) {
|
async fn seed_chapter(
|
||||||
|
pool: &PgPool,
|
||||||
|
manga_id: Uuid,
|
||||||
|
number: i32,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> Uuid {
|
||||||
// Historical seed — uploaded_by remains NULL, mirroring the
|
// Historical seed — uploaded_by remains NULL, mirroring the
|
||||||
// pre-Phase-5 rows in the production DB.
|
// pre-Phase-5 rows in the production DB.
|
||||||
mangalord::repo::chapter::create(pool, manga_id, number, title, None)
|
mangalord::repo::chapter::create(pool, manga_id, number, title, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
@@ -81,16 +87,16 @@ async fn list_chapters_returns_404_for_unknown_manga(pool: PgPool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_chapter_by_number(pool: PgPool) {
|
async fn get_chapter_by_id(pool: PgPool) {
|
||||||
let h = common::harness(pool.clone());
|
let h = common::harness(pool.clone());
|
||||||
let (_, cookie) = common::register_user(&h.app).await;
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
||||||
seed_chapter(&pool, manga_id, 1, Some("The Brand")).await;
|
let chapter_id = seed_chapter(&pool, manga_id, 1, Some("The Brand")).await;
|
||||||
|
|
||||||
let resp = h
|
let resp = h
|
||||||
.app
|
.app
|
||||||
.oneshot(common::get(&format!(
|
.oneshot(common::get(&format!(
|
||||||
"/api/v1/mangas/{manga_id}/chapters/1"
|
"/api/v1/mangas/{manga_id}/chapters/{chapter_id}"
|
||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -99,18 +105,20 @@ async fn get_chapter_by_number(pool: PgPool) {
|
|||||||
assert_eq!(body["number"], 1);
|
assert_eq!(body["number"], 1);
|
||||||
assert_eq!(body["title"], "The Brand");
|
assert_eq!(body["title"], "The Brand");
|
||||||
assert_eq!(body["page_count"], 0);
|
assert_eq!(body["page_count"], 0);
|
||||||
|
assert_eq!(body["id"], chapter_id.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_chapter_unknown_number_is_404(pool: PgPool) {
|
async fn get_chapter_unknown_id_is_404(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
let (_, cookie) = common::register_user(&h.app).await;
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
||||||
|
let unknown_chapter = Uuid::new_v4();
|
||||||
|
|
||||||
let resp = h
|
let resp = h
|
||||||
.app
|
.app
|
||||||
.oneshot(common::get(&format!(
|
.oneshot(common::get(&format!(
|
||||||
"/api/v1/mangas/{manga_id}/chapters/99"
|
"/api/v1/mangas/{manga_id}/chapters/{unknown_chapter}"
|
||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -122,10 +130,34 @@ async fn get_chapter_unknown_number_is_404(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_chapter_unknown_manga_is_404(pool: PgPool) {
|
async fn get_chapter_unknown_manga_is_404(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
let unknown = Uuid::nil();
|
let unknown_manga = Uuid::nil();
|
||||||
|
let unknown_chapter = Uuid::new_v4();
|
||||||
let resp = h
|
let resp = h
|
||||||
.app
|
.app
|
||||||
.oneshot(common::get(&format!("/api/v1/mangas/{unknown}/chapters/1")))
|
.oneshot(common::get(&format!(
|
||||||
|
"/api/v1/mangas/{unknown_manga}/chapters/{unknown_chapter}"
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-manga isolation: a chapter id belonging to manga A must not
|
||||||
|
/// resolve when accessed via manga B's URL. The (manga_id, id) scoping
|
||||||
|
/// in `find_by_id_in_manga` enforces this.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn get_chapter_from_wrong_manga_is_404(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let manga_a = seed_manga(&h, &cookie, "Berserk").await;
|
||||||
|
let manga_b = seed_manga(&h, &cookie, "Vagabond").await;
|
||||||
|
let chapter_id = seed_chapter(&pool, manga_a, 1, Some("Episode 1")).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::get(&format!(
|
||||||
|
"/api/v1/mangas/{manga_b}/chapters/{chapter_id}"
|
||||||
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
@@ -136,12 +168,12 @@ async fn list_pages_empty_for_chapter_without_upload(pool: PgPool) {
|
|||||||
let h = common::harness(pool.clone());
|
let h = common::harness(pool.clone());
|
||||||
let (_, cookie) = common::register_user(&h.app).await;
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
||||||
seed_chapter(&pool, manga_id, 1, None).await;
|
let chapter_id = seed_chapter(&pool, manga_id, 1, None).await;
|
||||||
|
|
||||||
let resp = h
|
let resp = h
|
||||||
.app
|
.app
|
||||||
.oneshot(common::get(&format!(
|
.oneshot(common::get(&format!(
|
||||||
"/api/v1/mangas/{manga_id}/chapters/1/pages"
|
"/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"
|
||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -155,11 +187,12 @@ async fn list_pages_returns_404_for_unknown_chapter(pool: PgPool) {
|
|||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
let (_, cookie) = common::register_user(&h.app).await;
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
|
||||||
|
let unknown_chapter = Uuid::new_v4();
|
||||||
|
|
||||||
let resp = h
|
let resp = h
|
||||||
.app
|
.app
|
||||||
.oneshot(common::get(&format!(
|
.oneshot(common::get(&format!(
|
||||||
"/api/v1/mangas/{manga_id}/chapters/99/pages"
|
"/api/v1/mangas/{manga_id}/chapters/{unknown_chapter}/pages"
|
||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -139,13 +139,17 @@ async fn files_endpoint_streams_in_multiple_frames(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
let chapter_id = common::body_json(resp).await["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Fetch the page back via the streaming files endpoint.
|
// Fetch the page back via the streaming files endpoint.
|
||||||
let pages = h
|
let pages = h
|
||||||
.app
|
.app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(common::get(&format!(
|
.oneshot(common::get(&format!(
|
||||||
"/api/v1/mangas/{manga_id}/chapters/1/pages"
|
"/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"
|
||||||
)))
|
)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -317,8 +321,12 @@ async fn create_chapter_rejects_renamed_non_image_page(pool: PgPool) {
|
|||||||
assert_eq!(body["error"]["code"], "unsupported_media_type");
|
assert_eq!(body["error"]["code"], "unsupported_media_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multiple chapters can share the same number — different
|
||||||
|
/// scanlations, re-uploads, translator notes. As of migration 0013,
|
||||||
|
/// (manga_id, number) is not unique and each upload gets its own
|
||||||
|
/// chapter id.
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn create_chapter_returns_409_on_duplicate_number(pool: PgPool) {
|
async fn create_chapter_allows_duplicate_numbers_as_separate_chapters(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
let (_, cookie) = common::register_user(&h.app).await;
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||||
@@ -334,10 +342,27 @@ async fn create_chapter_returns_409_on_duplicate_number(pool: PgPool) {
|
|||||||
};
|
};
|
||||||
let first = h.app.clone().oneshot(make()).await.unwrap();
|
let first = h.app.clone().oneshot(make()).await.unwrap();
|
||||||
assert_eq!(first.status(), StatusCode::CREATED);
|
assert_eq!(first.status(), StatusCode::CREATED);
|
||||||
let second = h.app.oneshot(make()).await.unwrap();
|
let first_id = common::body_json(first).await["id"].as_str().unwrap().to_string();
|
||||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
|
||||||
let body = common::body_json(second).await;
|
let second = h.app.clone().oneshot(make()).await.unwrap();
|
||||||
assert_eq!(body["error"]["code"], "conflict");
|
assert_eq!(second.status(), StatusCode::CREATED);
|
||||||
|
let second_id = common::body_json(second).await["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
assert_ne!(first_id, second_id, "each upload gets a distinct chapter id");
|
||||||
|
|
||||||
|
// List endpoint surfaces both rows.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::get(&format!("/api/v1/mangas/{manga_id}/chapters")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
let items = body["items"].as_array().unwrap();
|
||||||
|
assert_eq!(items.len(), 2, "both Ch.1 uploads listed separately");
|
||||||
|
for item in items {
|
||||||
|
assert_eq!(item["number"], 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
|||||||
@@ -232,6 +232,82 @@ async fn sync_chapters_adds_new_refreshes_existing_and_drops_vanished(pool: PgPo
|
|||||||
assert!(dropped.0.is_some(), "ch2 should be soft-dropped");
|
assert!(dropped.0.is_some(), "ch2 should be soft-dropped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real-world sources publish multiple chapters at the same number
|
||||||
|
/// (different uploaders, translator notes, re-releases). After the
|
||||||
|
/// (manga_id, number) UNIQUE drop in 0013, each `SourceChapterRef`
|
||||||
|
/// becomes its own `chapters` row even when the parsed number matches
|
||||||
|
/// — chapter identity is now the chapter id, not the number.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn sync_chapters_keeps_duplicate_numbered_chapters_as_separate_rows(pool: PgPool) {
|
||||||
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let m = sample_manga("foo", "Foo Manga", "hash-1");
|
||||||
|
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Two distinct uploads of Ch.52 (different uploaders → different
|
||||||
|
// URLs/keys, same parsed number) plus a notice/hiatus row that
|
||||||
|
// parses to number=0 alongside a real chapter at number 1.
|
||||||
|
let chapters = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "br_chapter-A".into(),
|
||||||
|
number: 52,
|
||||||
|
title: Some("Ch.52 : Official".into()),
|
||||||
|
url: "https://x.example/foo/A/pg-1/".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "br_chapter-B".into(),
|
||||||
|
number: 52,
|
||||||
|
title: Some("Ch.52 : Official (alt)".into()),
|
||||||
|
url: "https://x.example/foo/B/pg-1/".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "br_chapter-NOTICE".into(),
|
||||||
|
number: 0,
|
||||||
|
title: Some("hitaus.".into()),
|
||||||
|
url: "https://x.example/foo/notice/pg-1/".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "br_chapter-1".into(),
|
||||||
|
number: 1,
|
||||||
|
title: Some("Ch.1 : Official".into()),
|
||||||
|
url: "https://x.example/foo/1/pg-1/".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let diff = crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
diff,
|
||||||
|
ChapterDiff {
|
||||||
|
new: 4,
|
||||||
|
refreshed: 0,
|
||||||
|
dropped: 0
|
||||||
|
},
|
||||||
|
"every source ref yields a new chapter row"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM chapters WHERE manga_id = $1")
|
||||||
|
.bind(up.manga_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.0, 4, "4 distinct chapter rows even with duplicate numbers");
|
||||||
|
|
||||||
|
let ch52_count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM chapters WHERE manga_id = $1 AND number = 52",
|
||||||
|
)
|
||||||
|
.bind(up.manga_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ch52_count.0, 2, "both Ch.52 uploads survive as separate rows");
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn mark_dropped_mangas_only_drops_unseen(pool: PgPool) {
|
async fn mark_dropped_mangas_only_drops_unseen(pool: PgPool) {
|
||||||
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
const mangaId = '22222222-2222-2222-2222-222222222222';
|
const mangaId = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const chapterId = 'c2222222-2222-2222-2222-222222222222';
|
||||||
const mangaFixture = {
|
const mangaFixture = {
|
||||||
id: mangaId,
|
id: mangaId,
|
||||||
title: 'Vagabond',
|
title: 'Vagabond',
|
||||||
@@ -11,7 +12,7 @@ const mangaFixture = {
|
|||||||
updated_at: '2026-01-01T00:00:00Z'
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
};
|
};
|
||||||
const chapterFixture = {
|
const chapterFixture = {
|
||||||
id: 'c1',
|
id: chapterId,
|
||||||
manga_id: mangaId,
|
manga_id: mangaId,
|
||||||
number: 1,
|
number: 1,
|
||||||
title: null,
|
title: null,
|
||||||
@@ -20,24 +21,24 @@ const chapterFixture = {
|
|||||||
};
|
};
|
||||||
const pagesFixture = [
|
const pagesFixture = [
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1111111-2222-2222-2222-222222222222',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 1,
|
page_number: 1,
|
||||||
storage_key: 'mangas/m2/chapters/c1/pages/0001.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2222222-2222-2222-2222-222222222222',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 2,
|
page_number: 2,
|
||||||
storage_key: 'mangas/m2/chapters/c1/pages/0002.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0002.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3333333-2222-2222-2222-222222222222',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 3,
|
page_number: 3,
|
||||||
storage_key: 'mangas/m2/chapters/c1/pages/0003.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0003.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -92,19 +93,21 @@ async function mockReaderApis(page: Page) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1`, (route) =>
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify(chapterFixture)
|
body: JSON.stringify(chapterFixture)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) =>
|
await page.route(
|
||||||
route.fulfill({
|
`**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`,
|
||||||
status: 200,
|
(route) =>
|
||||||
contentType: 'application/json',
|
route.fulfill({
|
||||||
body: JSON.stringify({ pages: pagesFixture })
|
status: 200,
|
||||||
})
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ pages: pagesFixture })
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const png = Buffer.from(
|
const png = Buffer.from(
|
||||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||||
@@ -131,7 +134,7 @@ test.beforeEach(async ({ context }) => {
|
|||||||
|
|
||||||
test('switching to continuous mode stacks all pages and hides chevrons', async ({ page }) => {
|
test('switching to continuous mode stacks all pages and hides chevrons', async ({ page }) => {
|
||||||
await mockReaderApis(page);
|
await mockReaderApis(page);
|
||||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
// Default single-page mode is active.
|
// Default single-page mode is active.
|
||||||
await expect(page.getByTestId('reader-page')).toBeVisible();
|
await expect(page.getByTestId('reader-page')).toBeVisible();
|
||||||
@@ -149,7 +152,7 @@ test('switching to continuous mode stacks all pages and hides chevrons', async (
|
|||||||
|
|
||||||
test('arrow keys do not paginate while in continuous mode', async ({ page }) => {
|
test('arrow keys do not paginate while in continuous mode', async ({ page }) => {
|
||||||
await mockReaderApis(page);
|
await mockReaderApis(page);
|
||||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
await page.getByTestId('reader-mode-continuous').click();
|
await page.getByTestId('reader-mode-continuous').click();
|
||||||
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ test('arrow keys do not paginate while in continuous mode', async ({ page }) =>
|
|||||||
|
|
||||||
test('gap select updates the inline gap on the continuous container', async ({ page }) => {
|
test('gap select updates the inline gap on the continuous container', async ({ page }) => {
|
||||||
await mockReaderApis(page);
|
await mockReaderApis(page);
|
||||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
await page.getByTestId('reader-mode-continuous').click();
|
await page.getByTestId('reader-mode-continuous').click();
|
||||||
|
|
||||||
const container = page.getByTestId('reader-continuous');
|
const container = page.getByTestId('reader-continuous');
|
||||||
@@ -192,7 +195,7 @@ test('reader-mode preference set on one page is honored when the reader opens',
|
|||||||
});
|
});
|
||||||
await mockReaderApis(page);
|
await mockReaderApis(page);
|
||||||
|
|
||||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
|
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
|
||||||
await expect(page.getByTestId('reader-continuous')).toHaveAttribute(
|
await expect(page.getByTestId('reader-continuous')).toHaveAttribute(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
const mangaId = '11111111-1111-1111-1111-111111111111';
|
const mangaId = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const chapterId = 'c1111111-1111-1111-1111-111111111111';
|
||||||
const mangaFixture = {
|
const mangaFixture = {
|
||||||
id: mangaId,
|
id: mangaId,
|
||||||
title: 'Berserk',
|
title: 'Berserk',
|
||||||
@@ -12,7 +13,7 @@ const mangaFixture = {
|
|||||||
};
|
};
|
||||||
const chaptersFixture = [
|
const chaptersFixture = [
|
||||||
{
|
{
|
||||||
id: 'c1',
|
id: chapterId,
|
||||||
manga_id: mangaId,
|
manga_id: mangaId,
|
||||||
number: 1,
|
number: 1,
|
||||||
title: 'The Brand',
|
title: 'The Brand',
|
||||||
@@ -22,24 +23,24 @@ const chaptersFixture = [
|
|||||||
];
|
];
|
||||||
const pagesFixture = [
|
const pagesFixture = [
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1111111-1111-1111-1111-111111111111',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 1,
|
page_number: 1,
|
||||||
storage_key: 'mangas/m1/chapters/c1/pages/0001.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2222222-1111-1111-1111-111111111111',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 2,
|
page_number: 2,
|
||||||
storage_key: 'mangas/m1/chapters/c1/pages/0002.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0002.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3333333-1111-1111-1111-111111111111',
|
||||||
chapter_id: 'c1',
|
chapter_id: chapterId,
|
||||||
page_number: 3,
|
page_number: 3,
|
||||||
storage_key: 'mangas/m1/chapters/c1/pages/0003.png',
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0003.png`,
|
||||||
content_type: 'image/png'
|
content_type: 'image/png'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -86,19 +87,21 @@ async function mockReaderApis(page: Page) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1`, (route) =>
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify(chaptersFixture[0])
|
body: JSON.stringify(chaptersFixture[0])
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) =>
|
await page.route(
|
||||||
route.fulfill({
|
`**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`,
|
||||||
status: 200,
|
(route) =>
|
||||||
contentType: 'application/json',
|
route.fulfill({
|
||||||
body: JSON.stringify({ pages: pagesFixture })
|
status: 200,
|
||||||
})
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ pages: pagesFixture })
|
||||||
|
})
|
||||||
);
|
);
|
||||||
// Stub image bytes so the <img> doesn't 404 (1x1 transparent PNG).
|
// Stub image bytes so the <img> doesn't 404 (1x1 transparent PNG).
|
||||||
const png = Buffer.from(
|
const png = Buffer.from(
|
||||||
@@ -123,7 +126,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) =
|
|||||||
|
|
||||||
test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => {
|
test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => {
|
||||||
await mockReaderApis(page);
|
await mockReaderApis(page);
|
||||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||||
|
|
||||||
// Page 1 shown, preload for page 2 in the DOM.
|
// Page 1 shown, preload for page 2 in the DOM.
|
||||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.23.1",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -76,17 +76,17 @@ describe('chapters api client', () => {
|
|||||||
expect(result.page.total).toBeNull();
|
expect(result.page.total).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getChapter hits /v1/mangas/{id}/chapters/{n}', async () => {
|
it('getChapter hits /v1/mangas/{id}/chapters/{chapter_id}', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(ok(chapterFixture));
|
fetchSpy.mockResolvedValueOnce(ok(chapterFixture));
|
||||||
const c = await getChapter('m1', 1);
|
const c = await getChapter('m1', 'ch-uuid-1');
|
||||||
expect(c).toEqual(chapterFixture);
|
expect(c).toEqual(chapterFixture);
|
||||||
const url = fetchSpy.mock.calls[0][0] as string;
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/1$/);
|
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getChapter surfaces 404 via ApiError.code', async () => {
|
it('getChapter surfaces 404 via ApiError.code', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found'));
|
fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found'));
|
||||||
await expect(getChapter('m1', 99)).rejects.toMatchObject({
|
await expect(getChapter('m1', 'unknown-uuid')).rejects.toMatchObject({
|
||||||
status: 404,
|
status: 404,
|
||||||
code: 'not_found'
|
code: 'not_found'
|
||||||
});
|
});
|
||||||
@@ -143,10 +143,10 @@ describe('chapters api client', () => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const pages = await getChapterPages('m1', 1);
|
const pages = await getChapterPages('m1', 'ch-uuid-1');
|
||||||
expect(pages).toHaveLength(1);
|
expect(pages).toHaveLength(1);
|
||||||
expect(pages[0].storage_key).toContain('0001.png');
|
expect(pages[0].storage_key).toContain('0001.png');
|
||||||
const url = fetchSpy.mock.calls[0][0] as string;
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/1\/pages$/);
|
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/ch-uuid-1\/pages$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ export async function listChapters(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChapter(mangaId: string, number: number): Promise<Chapter> {
|
export async function getChapter(mangaId: string, chapterId: string): Promise<Chapter> {
|
||||||
return request<Chapter>(
|
return request<Chapter>(
|
||||||
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}`
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +48,10 @@ export type ChapterPage = {
|
|||||||
|
|
||||||
export async function getChapterPages(
|
export async function getChapterPages(
|
||||||
mangaId: string,
|
mangaId: string,
|
||||||
number: number
|
chapterId: string
|
||||||
): Promise<ChapterPage[]> {
|
): Promise<ChapterPage[]> {
|
||||||
const r = await request<{ pages: ChapterPage[] }>(
|
const r = await request<{ pages: ChapterPage[] }>(
|
||||||
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}/pages`
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}/pages`
|
||||||
);
|
);
|
||||||
return r.pages;
|
return r.pages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{#if b.chapter_id && b.chapter_number != null}
|
{#if b.chapter_id && b.chapter_number != null}
|
||||||
<a
|
<a
|
||||||
href="/manga/{b.manga_id}/chapter/{b.chapter_number}"
|
href="/manga/{b.manga_id}/chapter/{b.chapter_id}"
|
||||||
class="target"
|
class="target"
|
||||||
>
|
>
|
||||||
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
|
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
? chapters.find((c) => c.id === readProgress.chapter_id) ?? null
|
? chapters.find((c) => c.id === readProgress.chapter_id) ?? null
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
/** Reader link target — always the chapter id when we have one,
|
||||||
|
* even for chapters past the loaded `chapters` list page. */
|
||||||
|
const continueChapterId = $derived(readProgress?.chapter_id ?? null);
|
||||||
const continueChapterNumber = $derived(
|
const continueChapterNumber = $derived(
|
||||||
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
||||||
);
|
);
|
||||||
@@ -351,10 +354,10 @@
|
|||||||
|
|
||||||
<section aria-label="chapters">
|
<section aria-label="chapters">
|
||||||
<h2>Chapters</h2>
|
<h2>Chapters</h2>
|
||||||
{#if continueChapterNumber != null}
|
{#if continueChapterId != null && continueChapterNumber != null}
|
||||||
<a
|
<a
|
||||||
class="continue"
|
class="continue"
|
||||||
href="/manga/{manga.id}/chapter/{continueChapterNumber}"
|
href="/manga/{manga.id}/chapter/{continueChapterId}"
|
||||||
data-testid="continue-reading"
|
data-testid="continue-reading"
|
||||||
>
|
>
|
||||||
<span class="continue-label">Continue reading</span>
|
<span class="continue-label">Continue reading</span>
|
||||||
@@ -372,7 +375,7 @@
|
|||||||
<ol class="chapter-list" data-testid="chapter-list">
|
<ol class="chapter-list" data-testid="chapter-list">
|
||||||
{#each chapters as c (c.id)}
|
{#each chapters as c (c.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href="/manga/{manga.id}/chapter/{c.number}">
|
<a href="/manga/{manga.id}/chapter/{c.id}">
|
||||||
Chapter {c.number}{#if c.title}: {c.title}{/if}
|
Chapter {c.number}{#if c.title}: {c.title}{/if}
|
||||||
</a>
|
</a>
|
||||||
<span class="pages">({c.page_count} pages)</span>
|
<span class="pages">({c.page_count} pages)</span>
|
||||||
|
|||||||
@@ -135,11 +135,11 @@
|
|||||||
// navigation feels continuous in single mode. Harmless in
|
// navigation feels continuous in single mode. Harmless in
|
||||||
// continuous mode (the reader just shows everything).
|
// continuous mode (the reader just shows everything).
|
||||||
const target = mode === 'single' ? `?page=last` : '';
|
const target = mode === 'single' ? `?page=last` : '';
|
||||||
void goto(`/manga/${manga.id}/chapter/${prevChapter.number}${target}`);
|
void goto(`/manga/${manga.id}/chapter/${prevChapter.id}${target}`);
|
||||||
}
|
}
|
||||||
function jumpToNextChapter() {
|
function jumpToNextChapter() {
|
||||||
if (!nextChapter) return;
|
if (!nextChapter) return;
|
||||||
void goto(`/manga/${manga.id}/chapter/${nextChapter.number}`);
|
void goto(`/manga/${manga.id}/chapter/${nextChapter.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
@@ -6,11 +6,10 @@ import type { PageLoad } from './$types';
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, url }) => {
|
export const load: PageLoad = async ({ params, url }) => {
|
||||||
const number = Number(params.n);
|
|
||||||
const [manga, chapter, pages, readProgress, chapterList] = await Promise.all([
|
const [manga, chapter, pages, readProgress, chapterList] = await Promise.all([
|
||||||
getManga(params.id),
|
getManga(params.id),
|
||||||
getChapter(params.id, number),
|
getChapter(params.id, params.chapter_id),
|
||||||
getChapterPages(params.id, number),
|
getChapterPages(params.id, params.chapter_id),
|
||||||
// `null` for guests or first-time openers — the reader uses
|
// `null` for guests or first-time openers — the reader uses
|
||||||
// this to seed its session-local high-water mark.
|
// this to seed its session-local high-water mark.
|
||||||
getMyReadProgressForManga(params.id),
|
getMyReadProgressForManga(params.id),
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
{#each progress as p (p.manga_id)}
|
{#each progress as p (p.manga_id)}
|
||||||
<li class="entry">
|
<li class="entry">
|
||||||
<a
|
<a
|
||||||
href={p.chapter_number != null
|
href={p.chapter_id != null
|
||||||
? `/manga/${p.manga_id}/chapter/${p.chapter_number}`
|
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||||
: `/manga/${p.manga_id}`}
|
: `/manga/${p.manga_id}`}
|
||||||
class="cover-link"
|
class="cover-link"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -89,9 +89,9 @@
|
|||||||
{p.manga_title}
|
{p.manga_title}
|
||||||
</a>
|
</a>
|
||||||
<span class="target">
|
<span class="target">
|
||||||
{#if p.chapter_number != null}
|
{#if p.chapter_id != null && p.chapter_number != null}
|
||||||
<a
|
<a
|
||||||
href="/manga/{p.manga_id}/chapter/{p.chapter_number}"
|
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
||||||
>
|
>
|
||||||
Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if}
|
Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if}
|
||||||
</a>
|
</a>
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
|
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
|
||||||
<span class="target">
|
<span class="target">
|
||||||
<a href="/manga/{u.manga_id}/chapter/{u.chapter.number}">
|
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
|
||||||
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
|
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
|
||||||
</a>
|
</a>
|
||||||
<span class="muted">({u.chapter.page_count} pages)</span>
|
<span class="muted">({u.chapter.page_count} pages)</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user