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:
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
@@ -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),
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user