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:
@@ -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(
|
||||
|
||||
@@ -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 <img> 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');
|
||||
|
||||
Reference in New Issue
Block a user