Files
Mangalord/frontend/src/lib/api/chapters.ts
MechaCat02 a44511983d
All checks were successful
deploy / test-backend (push) Successful in 44m20s
deploy / test-frontend (push) Successful in 11m3s
deploy / build-and-push (push) Successful in 13m53s
deploy / deploy (push) Successful in 20s
fix: page through all chapters when the API omits total
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>
2026-07-11 16:24:49 +02:00

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] ?? '';
}