Files
Mangalord/frontend/src/lib/api/chapters.ts
MechaCat02 790549636f
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:

- Admin System tab: total/covers/chapters totals, ratio of disk, average
  image sizes, and top-5 largest mangas/chapters leaderboards, behind a
  new GET /api/v1/admin/storage endpoint (separate from /admin/system so
  the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
  with an em-dash for uncrawled or not-yet-measured chapters.

Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.

Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:18:41 +02:00

129 lines
3.8 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}` : ''}`
);
}
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] ?? '';
}