listAllChapters used `page.total ?? all.length` as the loop bound. The /chapters endpoint always returns `total: null`, so a full 200-row first page broke the loop immediately — the reader dead-ended past chapter 200. Stop on a short page instead; only trust `total` when actually reported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.9 KiB
TypeScript
154 lines
4.9 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;
|
|
/** Total bytes of this chapter's stored pages. `0` for an uncrawled
|
|
* chapter; `null` when the size is unknown (a page predates the size
|
|
* backfill). Render both `null` and uncrawled as an em-dash. */
|
|
size_bytes: number | null;
|
|
};
|
|
|
|
export type ChaptersPage = {
|
|
items: Chapter[];
|
|
page: Page;
|
|
};
|
|
|
|
export function chapterLabel(c: Pick<Chapter, 'number' | 'title'>): string {
|
|
return c.title ?? `Chapter ${c.number}`;
|
|
}
|
|
|
|
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}` : ''}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Every chapter of a manga, in display order, paging through the API (which
|
|
* clamps `limit` to 200 per request). The reader needs the complete list so
|
|
* prev/next navigation and the chapter dropdown work for a deep chapter — a
|
|
* single 200-row window dead-ended chapter #250 with null neighbours.
|
|
*/
|
|
export async function listAllChapters(mangaId: string): Promise<Chapter[]> {
|
|
const PAGE = 200;
|
|
const all: Chapter[] = [];
|
|
let offset = 0;
|
|
// Hard iteration cap (200 * 100 = 20k chapters) so a bad `total` can never
|
|
// spin forever.
|
|
for (let i = 0; i < 100; i++) {
|
|
const { items, page } = await listChapters(mangaId, { limit: PAGE, offset });
|
|
all.push(...items);
|
|
offset += items.length;
|
|
// A short page is the only reliable end-of-list signal: the /chapters
|
|
// endpoint returns `total: null`, so we can't infer completion from it.
|
|
// Only stop early on `total` when the API actually reports one.
|
|
if (items.length < PAGE) break;
|
|
if (page.total != null && all.length >= page.total) break;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
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] ?? '';
|
|
}
|