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): string { return c.title ?? `Chapter ${c.number}`; } export type ListOptions = { limit?: number; offset?: number; }; export async function listChapters( mangaId: string, opts: ListOptions = {} ): Promise { 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( `/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 { 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 { return request( `/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 { 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.` 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 { 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( `/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 = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/avif': '.avif' }; return fromMime[file.type] ?? ''; }