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>
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
import { request, type Page } from './client';
|
|
|
|
export type Chapter = {
|
|
id: string;
|
|
manga_id: string;
|
|
number: number;
|
|
title: string | null;
|
|
page_count: number;
|
|
created_at: string;
|
|
};
|
|
|
|
export type ChaptersPage = {
|
|
items: Chapter[];
|
|
page: Page;
|
|
};
|
|
|
|
export type ListOptions = {
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
export async function listChapters(
|
|
mangaId: string,
|
|
opts: ListOptions = {}
|
|
): Promise<ChaptersPage> {
|
|
const params = new URLSearchParams();
|
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
|
const qs = params.toString();
|
|
return request<ChaptersPage>(
|
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}`
|
|
);
|
|
}
|
|
|
|
export async function getChapter(mangaId: string, chapterId: string): Promise<Chapter> {
|
|
return request<Chapter>(
|
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}`
|
|
);
|
|
}
|
|
|
|
export type ChapterPage = {
|
|
id: string;
|
|
chapter_id: string;
|
|
page_number: number;
|
|
storage_key: string;
|
|
content_type: string;
|
|
};
|
|
|
|
export async function getChapterPages(
|
|
mangaId: string,
|
|
chapterId: string
|
|
): Promise<ChapterPage[]> {
|
|
const r = await request<{ pages: ChapterPage[] }>(
|
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${encodeURIComponent(chapterId)}/pages`
|
|
);
|
|
return r.pages;
|
|
}
|
|
|
|
export type NewChapter = {
|
|
number: number;
|
|
title?: string | null;
|
|
};
|
|
|
|
/**
|
|
* `POST /api/v1/mangas/:id/chapters` is multipart: a `metadata` part
|
|
* (JSON) plus one or more ordered `page` parts. Each page file is
|
|
* renamed to `page-NNN.<ext>` before submission so the user's
|
|
* original filenames (often personally-identifying or just messy:
|
|
* `IMG_2837.HEIC`, `~/scans/full chapter pack/`) don't end up in
|
|
* request bodies or server logs. The bytes are unchanged — the
|
|
* backend still sniffs the MIME from magic bytes and stores under
|
|
* its own `{nnnn}.{ext}` scheme.
|
|
*/
|
|
export async function createChapter(
|
|
mangaId: string,
|
|
metadata: NewChapter,
|
|
pages: File[]
|
|
): Promise<Chapter> {
|
|
const form = new FormData();
|
|
form.append(
|
|
'metadata',
|
|
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
|
|
);
|
|
pages.forEach((file, i) => {
|
|
const ext = extensionFor(file);
|
|
const renamed = new File(
|
|
[file],
|
|
`page-${String(i + 1).padStart(3, '0')}${ext}`,
|
|
{ type: file.type }
|
|
);
|
|
form.append('page', renamed);
|
|
});
|
|
return request<Chapter>(
|
|
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters`,
|
|
{ method: 'POST', body: form }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pick a sensible extension for the renamed multipart part. Prefer
|
|
* the original filename's extension when present (jpg/jpeg/png/webp/
|
|
* gif/avif), otherwise derive from the MIME type. Falls back to an
|
|
* empty string so the renamed file is just `page-001` — the
|
|
* server sniffs bytes anyway.
|
|
*/
|
|
function extensionFor(file: File): string {
|
|
const dot = file.name.lastIndexOf('.');
|
|
if (dot > 0) {
|
|
const ext = file.name.slice(dot).toLowerCase();
|
|
if (/^\.(jpe?g|png|webp|gif|avif)$/.test(ext)) return ext;
|
|
}
|
|
const fromMime: Record<string, string> = {
|
|
'image/jpeg': '.jpg',
|
|
'image/png': '.png',
|
|
'image/webp': '.webp',
|
|
'image/gif': '.gif',
|
|
'image/avif': '.avif'
|
|
};
|
|
return fromMime[file.type] ?? '';
|
|
}
|