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>
129 lines
3.8 KiB
TypeScript
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] ?? '';
|
|
}
|