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:
MechaCat02
2026-05-22 23:37:07 +02:00
parent c51353ead3
commit 51346227dd
19 changed files with 274 additions and 104 deletions

View File

@@ -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(

View File

@@ -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');

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.23.1",
"version": "0.24.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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$/);
});
});

View File

@@ -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>(
`/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<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;
}

View File

@@ -39,7 +39,7 @@
</a>
{#if b.chapter_id && b.chapter_number != null}
<a
href="/manga/{b.manga_id}/chapter/{b.chapter_number}"
href="/manga/{b.manga_id}/chapter/{b.chapter_id}"
class="target"
>
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}

View File

@@ -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 @@
<section aria-label="chapters">
<h2>Chapters</h2>
{#if continueChapterNumber != null}
{#if continueChapterId != null && continueChapterNumber != null}
<a
class="continue"
href="/manga/{manga.id}/chapter/{continueChapterNumber}"
href="/manga/{manga.id}/chapter/{continueChapterId}"
data-testid="continue-reading"
>
<span class="continue-label">Continue reading</span>
@@ -372,7 +375,7 @@
<ol class="chapter-list" data-testid="chapter-list">
{#each chapters as c (c.id)}
<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}
</a>
<span class="pages">({c.page_count} pages)</span>

View File

@@ -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() {

View File

@@ -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),

View File

@@ -60,8 +60,8 @@
{#each progress as p (p.manga_id)}
<li class="entry">
<a
href={p.chapter_number != null
? `/manga/${p.manga_id}/chapter/${p.chapter_number}`
href={p.chapter_id != null
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
: `/manga/${p.manga_id}`}
class="cover-link"
tabindex="-1"
@@ -89,9 +89,9 @@
{p.manga_title}
</a>
<span class="target">
{#if p.chapter_number != null}
{#if p.chapter_id != null && p.chapter_number != null}
<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}
</a>
@@ -185,7 +185,7 @@
<div class="meta">
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
<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}
</a>
<span class="muted">({u.chapter.page_count} pages)</span>