diff --git a/backend/Cargo.lock b/backend/Cargo.lock index df2da46..25f3ccb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1415,7 +1415,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.23.1" +version = "0.24.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4900858..c3ca581 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.23.1" +version = "0.24.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0013_drop_chapters_unique_number.sql b/backend/migrations/0013_drop_chapters_unique_number.sql new file mode 100644 index 0000000..bcd1fb9 --- /dev/null +++ b/backend/migrations/0013_drop_chapters_unique_number.sql @@ -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); diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index ca5e5a7..26578f9 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -26,9 +26,9 @@ 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/:chapter_id", get(get_one)) .route( - "/mangas/:manga_id/chapters/:number/pages", + "/mangas/:manga_id/chapters/:chapter_id/pages", get(list_pages), ) } @@ -60,10 +60,10 @@ async fn list( async fn get_one( State(state): State, - Path((manga_id, number)): Path<(Uuid, i32)>, + Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>, ) -> AppResult> { 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? .ok_or(AppError::NotFound)?; Ok(Json(chapter)) @@ -164,10 +164,10 @@ struct PagesResponse { async fn list_pages( State(state): State, - Path((manga_id, number)): Path<(Uuid, i32)>, + Path((manga_id, chapter_id)): Path<(Uuid, Uuid)>, ) -> AppResult> { 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? .ok_or(AppError::NotFound)?; let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?; diff --git a/backend/src/repo/chapter.rs b/backend/src/repo/chapter.rs index 50b3de1..73f06f5 100644 --- a/backend/src/repo/chapter.rs +++ b/backend/src/repo/chapter.rs @@ -12,12 +12,15 @@ pub async fn list_for_manga( limit: i64, offset: i64, ) -> AppResult> { + // 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>( r#" SELECT id, manga_id, number, title, page_count, created_at FROM chapters WHERE manga_id = $1 - ORDER BY number ASC + ORDER BY number ASC, created_at ASC LIMIT $2 OFFSET $3 "#, ) @@ -29,33 +32,40 @@ pub async fn list_for_manga( 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, manga_id: Uuid, - number: i32, + chapter_id: Uuid, ) -> AppResult> { let row = sqlx::query_as::<_, Chapter>( r#" SELECT id, manga_id, number, title, page_count, created_at FROM chapters - WHERE manga_id = $1 AND number = $2 + WHERE manga_id = $1 AND id = $2 "#, ) .bind(manga_id) - .bind(number) + .bind(chapter_id) .fetch_optional(pool) .await?; Ok(row) } /// Accepts any `PgExecutor` so the upload handler can run this inside a -/// transaction with the per-page inserts. Returns `AppError::Conflict` -/// on the (manga_id, number) unique violation so handlers can surface a -/// clean 409. +/// transaction with the per-page inserts. /// /// `uploaded_by` records who uploaded the chapter and feeds the /// per-user upload history. `None` means "historical / API token with /// 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>>( executor: E, manga_id: Uuid, @@ -80,7 +90,7 @@ pub async fn create<'e, E: PgExecutor<'e>>( match result { Ok(c) => Ok(c), 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)), } diff --git a/backend/src/repo/crawler.rs b/backend/src/repo/crawler.rs index 611949f..2645323 100644 --- a/backend/src/repo/crawler.rs +++ b/backend/src/repo/crawler.rs @@ -332,15 +332,15 @@ pub async fn sync_manga_chapters( match existing { None => { - // New chapter row. The (manga_id, number) unique - // constraint protects against re-inserts if the same - // number arrives via a different source_chapter_key. + // New chapter row. As of 0013 there's no (manga_id, + // number) UNIQUE, so duplicate-numbered chapters from + // 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( r#" INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, $3, 0) - ON CONFLICT (manga_id, number) DO UPDATE - SET title = EXCLUDED.title RETURNING id "#, ) diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index cc36a08..a87adde 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -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 } -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 // pre-Phase-5 rows in the production DB. mangalord::repo::chapter::create(pool, manga_id, number, title, None) .await - .unwrap(); + .unwrap() + .id } #[sqlx::test(migrations = "./migrations")] @@ -81,16 +87,16 @@ async fn list_chapters_returns_404_for_unknown_manga(pool: PgPool) { } #[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 (_, cookie) = common::register_user(&h.app).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 .app .oneshot(common::get(&format!( - "/api/v1/mangas/{manga_id}/chapters/1" + "/api/v1/mangas/{manga_id}/chapters/{chapter_id}" ))) .await .unwrap(); @@ -99,18 +105,20 @@ async fn get_chapter_by_number(pool: PgPool) { assert_eq!(body["number"], 1); assert_eq!(body["title"], "The Brand"); assert_eq!(body["page_count"], 0); + assert_eq!(body["id"], chapter_id.to_string()); } #[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 (_, cookie) = common::register_user(&h.app).await; let manga_id = seed_manga(&h, &cookie, "Berserk").await; + let unknown_chapter = Uuid::new_v4(); let resp = h .app .oneshot(common::get(&format!( - "/api/v1/mangas/{manga_id}/chapters/99" + "/api/v1/mangas/{manga_id}/chapters/{unknown_chapter}" ))) .await .unwrap(); @@ -122,10 +130,34 @@ async fn get_chapter_unknown_number_is_404(pool: PgPool) { #[sqlx::test(migrations = "./migrations")] async fn get_chapter_unknown_manga_is_404(pool: PgPool) { let h = common::harness(pool); - let unknown = Uuid::nil(); + let unknown_manga = Uuid::nil(); + let unknown_chapter = Uuid::new_v4(); let resp = h .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 .unwrap(); 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 (_, cookie) = common::register_user(&h.app).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 .app .oneshot(common::get(&format!( - "/api/v1/mangas/{manga_id}/chapters/1/pages" + "/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages" ))) .await .unwrap(); @@ -155,11 +187,12 @@ async fn list_pages_returns_404_for_unknown_chapter(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let manga_id = seed_manga(&h, &cookie, "Berserk").await; + let unknown_chapter = Uuid::new_v4(); let resp = h .app .oneshot(common::get(&format!( - "/api/v1/mangas/{manga_id}/chapters/99/pages" + "/api/v1/mangas/{manga_id}/chapters/{unknown_chapter}/pages" ))) .await .unwrap(); diff --git a/backend/tests/api_uploads.rs b/backend/tests/api_uploads.rs index aabfddc..e682211 100644 --- a/backend/tests/api_uploads.rs +++ b/backend/tests/api_uploads.rs @@ -139,13 +139,17 @@ async fn files_endpoint_streams_in_multiple_frames(pool: PgPool) { .await .unwrap(); 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. let pages = h .app .clone() .oneshot(common::get(&format!( - "/api/v1/mangas/{manga_id}/chapters/1/pages" + "/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages" ))) .await .unwrap(); @@ -317,8 +321,12 @@ async fn create_chapter_rejects_renamed_non_image_page(pool: PgPool) { 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")] -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 (_, cookie) = common::register_user(&h.app).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(); assert_eq!(first.status(), StatusCode::CREATED); - let second = h.app.oneshot(make()).await.unwrap(); - assert_eq!(second.status(), StatusCode::CONFLICT); - let body = common::body_json(second).await; - assert_eq!(body["error"]["code"], "conflict"); + let first_id = common::body_json(first).await["id"].as_str().unwrap().to_string(); + + let second = h.app.clone().oneshot(make()).await.unwrap(); + 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")] diff --git a/backend/tests/crawler_sync.rs b/backend/tests/crawler_sync.rs index 6981a24..27f6f3b 100644 --- a/backend/tests/crawler_sync.rs +++ b/backend/tests/crawler_sync.rs @@ -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"); } +/// 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")] async fn mark_dropped_mangas_only_drops_unseen(pool: PgPool) { crawler::ensure_source(&pool, "target", "T", "https://x.example") diff --git a/frontend/e2e/reader-mode.spec.ts b/frontend/e2e/reader-mode.spec.ts index e9e1d91..b31cd8b 100644 --- a/frontend/e2e/reader-mode.spec.ts +++ b/frontend/e2e/reader-mode.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from '@playwright/test'; const mangaId = '22222222-2222-2222-2222-222222222222'; +const chapterId = 'c2222222-2222-2222-2222-222222222222'; const mangaFixture = { id: mangaId, title: 'Vagabond', @@ -11,7 +12,7 @@ const mangaFixture = { updated_at: '2026-01-01T00:00:00Z' }; const chapterFixture = { - id: 'c1', + id: chapterId, manga_id: mangaId, number: 1, title: null, @@ -20,24 +21,24 @@ const chapterFixture = { }; const pagesFixture = [ { - id: 'p1', - chapter_id: 'c1', + id: 'p1111111-2222-2222-2222-222222222222', + chapter_id: chapterId, 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' }, { - id: 'p2', - chapter_id: 'c1', + id: 'p2222222-2222-2222-2222-222222222222', + chapter_id: chapterId, 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' }, { - id: 'p3', - chapter_id: 'c1', + id: 'p3333333-2222-2222-2222-222222222222', + chapter_id: chapterId, 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' } ]; @@ -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({ status: 200, contentType: 'application/json', body: JSON.stringify(chapterFixture) }) ); - await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ pages: pagesFixture }) - }) + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: pagesFixture }) + }) ); const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', @@ -131,7 +134,7 @@ test.beforeEach(async ({ context }) => { test('switching to continuous mode stacks all pages and hides chevrons', async ({ page }) => { await mockReaderApis(page); - await page.goto(`/manga/${mangaId}/chapter/1`); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); // Default single-page mode is active. 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 }) => { 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 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 }) => { await mockReaderApis(page); - await page.goto(`/manga/${mangaId}/chapter/1`); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.getByTestId('reader-mode-continuous').click(); 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 page.goto(`/manga/${mangaId}/chapter/1`); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('page-indicator')).toHaveText('3 pages'); await expect(page.getByTestId('reader-continuous')).toHaveAttribute( diff --git a/frontend/e2e/reader.spec.ts b/frontend/e2e/reader.spec.ts index 5e95829..78ce8aa 100644 --- a/frontend/e2e/reader.spec.ts +++ b/frontend/e2e/reader.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from '@playwright/test'; const mangaId = '11111111-1111-1111-1111-111111111111'; +const chapterId = 'c1111111-1111-1111-1111-111111111111'; const mangaFixture = { id: mangaId, title: 'Berserk', @@ -12,7 +13,7 @@ const mangaFixture = { }; const chaptersFixture = [ { - id: 'c1', + id: chapterId, manga_id: mangaId, number: 1, title: 'The Brand', @@ -22,24 +23,24 @@ const chaptersFixture = [ ]; const pagesFixture = [ { - id: 'p1', - chapter_id: 'c1', + id: 'p1111111-1111-1111-1111-111111111111', + chapter_id: chapterId, 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' }, { - id: 'p2', - chapter_id: 'c1', + id: 'p2222222-1111-1111-1111-111111111111', + chapter_id: chapterId, 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' }, { - id: 'p3', - chapter_id: 'c1', + id: 'p3333333-1111-1111-1111-111111111111', + chapter_id: chapterId, 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' } ]; @@ -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({ status: 200, contentType: 'application/json', body: JSON.stringify(chaptersFixture[0]) }) ); - await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ pages: pagesFixture }) - }) + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: pagesFixture }) + }) ); // Stub image bytes so the doesn't 404 (1x1 transparent PNG). 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 }) => { 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. await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3'); diff --git a/frontend/package.json b/frontend/package.json index c4df14d..338a1c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.23.1", + "version": "0.24.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/chapters.test.ts b/frontend/src/lib/api/chapters.test.ts index 1e8034d..37d2779 100644 --- a/frontend/src/lib/api/chapters.test.ts +++ b/frontend/src/lib/api/chapters.test.ts @@ -76,17 +76,17 @@ describe('chapters api client', () => { 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)); - const c = await getChapter('m1', 1); + const c = await getChapter('m1', 'ch-uuid-1'); expect(c).toEqual(chapterFixture); 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 () => { 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, 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[0].storage_key).toContain('0001.png'); 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$/); }); }); diff --git a/frontend/src/lib/api/chapters.ts b/frontend/src/lib/api/chapters.ts index a9f4315..2247c84 100644 --- a/frontend/src/lib/api/chapters.ts +++ b/frontend/src/lib/api/chapters.ts @@ -32,9 +32,9 @@ export async function listChapters( ); } -export async function getChapter(mangaId: string, number: number): Promise { +export async function getChapter(mangaId: string, chapterId: string): Promise { return request( - `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}` + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}` ); } @@ -48,10 +48,10 @@ export type ChapterPage = { export async function getChapterPages( mangaId: string, - number: number + chapterId: string ): Promise { const r = await request<{ pages: ChapterPage[] }>( - `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}/pages` + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}/pages` ); return r.pages; } diff --git a/frontend/src/lib/components/BookmarkList.svelte b/frontend/src/lib/components/BookmarkList.svelte index 7f6cdea..9218c78 100644 --- a/frontend/src/lib/components/BookmarkList.svelte +++ b/frontend/src/lib/components/BookmarkList.svelte @@ -39,7 +39,7 @@ {#if b.chapter_id && b.chapter_number != null} Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if} diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index d3203f1..59d61e0 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -29,6 +29,9 @@ ? chapters.find((c) => c.id === readProgress.chapter_id) ?? 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( continueChapter?.number ?? readProgress?.chapter_number ?? null ); @@ -351,10 +354,10 @@

Chapters

- {#if continueChapterNumber != null} + {#if continueChapterId != null && continueChapterNumber != null}
Continue reading @@ -372,7 +375,7 @@
    {#each chapters as c (c.id)}
  1. - + Chapter {c.number}{#if c.title}: {c.title}{/if} ({c.page_count} pages) diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte similarity index 99% rename from frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte rename to frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte index 59c23f2..e0334b2 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -135,11 +135,11 @@ // navigation feels continuous in single mode. Harmless in // continuous mode (the reader just shows everything). 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() { if (!nextChapter) return; - void goto(`/manga/${manga.id}/chapter/${nextChapter.number}`); + void goto(`/manga/${manga.id}/chapter/${nextChapter.id}`); } function next() { diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.ts similarity index 93% rename from frontend/src/routes/manga/[id]/chapter/[n]/+page.ts rename to frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.ts index 4f4dc2b..0986c0f 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.ts @@ -6,11 +6,10 @@ import type { PageLoad } from './$types'; export const ssr = false; export const load: PageLoad = async ({ params, url }) => { - const number = Number(params.n); const [manga, chapter, pages, readProgress, chapterList] = await Promise.all([ getManga(params.id), - getChapter(params.id, number), - getChapterPages(params.id, number), + getChapter(params.id, params.chapter_id), + getChapterPages(params.id, params.chapter_id), // `null` for guests or first-time openers — the reader uses // this to seed its session-local high-water mark. getMyReadProgressForManga(params.id), diff --git a/frontend/src/routes/profile/history/+page.svelte b/frontend/src/routes/profile/history/+page.svelte index 9ae0218..7676743 100644 --- a/frontend/src/routes/profile/history/+page.svelte +++ b/frontend/src/routes/profile/history/+page.svelte @@ -60,8 +60,8 @@ {#each progress as p (p.manga_id)}
  2. - {#if p.chapter_number != null} + {#if p.chapter_id != null && p.chapter_number != null} Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if} @@ -185,7 +185,7 @@