Files
Mangalord/frontend/src/lib/api/mangas.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

186 lines
6.0 KiB
TypeScript

import { request, type Manga, type MangaStatus, type Page } from './client';
import type { ContentWarning } from './page_tags';
export type MangaSort = 'recent' | 'title';
export type AuthorRef = { id: string; name: string };
export type GenreRef = { id: string; name: string };
export type TagRef = { id: string; name: string; added_by: string | null };
/** Card shape returned by `GET /v1/mangas` — authors + genres, no tags. */
export type MangaCard = Manga & {
authors: AuthorRef[];
genres: GenreRef[];
};
/** Detail shape returned by `GET /v1/mangas/:id`. Includes user tags. */
export type MangaDetail = Manga & {
authors: AuthorRef[];
genres: GenreRef[];
tags: TagRef[];
/** Deduped union of content warnings across the manga's pages. */
content_warnings: ContentWarning[];
/** Total bytes of all this manga's stored chapter pages (cover
* excluded). `null` when any page is unmeasured (show an em-dash);
* `0` when there are no pages. */
chapter_storage_bytes: number | null;
};
export type ListOptions = {
search?: string;
status?: MangaStatus;
/** AND across the list — every id must be attached to the manga. */
authorIds?: string[];
genreIds?: string[];
tagIds?: string[];
/** Content warnings the manga must carry (all) / must not carry (any). */
cwInclude?: ContentWarning[];
cwExclude?: ContentWarning[];
limit?: number;
offset?: number;
sort?: MangaSort;
};
export type MangasPage = {
items: MangaCard[];
page: Page;
};
export async function listMangas(opts: ListOptions = {}): Promise<MangasPage> {
const params = new URLSearchParams();
if (opts.search) params.set('search', opts.search);
if (opts.status) params.set('status', opts.status);
if (opts.authorIds && opts.authorIds.length) {
params.set('author_id', opts.authorIds.join(','));
}
if (opts.genreIds && opts.genreIds.length) {
params.set('genre_id', opts.genreIds.join(','));
}
if (opts.tagIds && opts.tagIds.length) {
params.set('tag_id', opts.tagIds.join(','));
}
if (opts.cwInclude && opts.cwInclude.length) {
params.set('cw_include', opts.cwInclude.join(','));
}
if (opts.cwExclude && opts.cwExclude.length) {
params.set('cw_exclude', opts.cwExclude.join(','));
}
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
if (opts.sort) params.set('sort', opts.sort);
const qs = params.toString();
return request<MangasPage>(`/v1/mangas${qs ? `?${qs}` : ''}`);
}
export async function getManga(id: string): Promise<MangaDetail> {
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`);
}
/**
* GET /v1/mangas/:id/similar — up to 5 mangas ranked by tag overlap with
* `id`. Returns a plain `{ items }` object (a fixed top-N, not a paginated
* collection), so we unwrap to the card array the page wants.
*/
export async function getSimilarMangas(id: string): Promise<MangaCard[]> {
const res = await request<{ items: MangaCard[] }>(
`/v1/mangas/${encodeURIComponent(id)}/similar`
);
// Defensive: a malformed 200 body (items omitted) must still yield an
// array so the page's `similar.length` guard can't throw.
return res.items ?? [];
}
export type NewManga = {
title: string;
status?: MangaStatus;
/** Author display names; resolved server-side, case-insensitive. */
authors?: string[];
description?: string | null;
alt_titles?: string[];
genre_ids?: string[];
};
/**
* POST /api/v1/mangas is multipart. The metadata part is JSON; the cover
* part is the raw image bytes. The browser fills in the multipart boundary
* automatically when `body` is a FormData, so we deliberately do not set
* Content-Type ourselves.
*/
export async function createManga(
input: NewManga,
cover?: Blob
): Promise<MangaDetail> {
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(input)], { type: 'application/json' })
);
if (cover) form.append('cover', cover);
return request<MangaDetail>('/v1/mangas', { method: 'POST', body: form });
}
export type MangaPatch = {
title?: string;
status?: MangaStatus;
description?: string | null;
alt_titles?: string[];
authors?: string[];
genre_ids?: string[];
};
export async function updateManga(
id: string,
patch: MangaPatch
): Promise<MangaDetail> {
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
}
/**
* PUT /api/v1/mangas/:id/cover (multipart). Replaces the cover image and
* returns the refreshed detail. As with createManga the browser fills in
* the multipart boundary automatically, so we must NOT set Content-Type.
*/
export async function updateMangaCover(
id: string,
cover: Blob
): Promise<MangaDetail> {
const form = new FormData();
form.append('cover', cover);
return request<MangaDetail>(
`/v1/mangas/${encodeURIComponent(id)}/cover`,
{ method: 'PUT', body: form }
);
}
/** DELETE /api/v1/mangas/:id/cover. Returns the refreshed detail. */
export async function deleteMangaCover(id: string): Promise<MangaDetail> {
return request<MangaDetail>(
`/v1/mangas/${encodeURIComponent(id)}/cover`,
{ method: 'DELETE' }
);
}
export async function attachTag(
mangaId: string,
name: string
): Promise<TagRef> {
return request<TagRef>(`/v1/mangas/${encodeURIComponent(mangaId)}/tags`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name })
});
}
export async function detachTag(mangaId: string, tagId: string): Promise<void> {
await request<void>(
`/v1/mangas/${encodeURIComponent(mangaId)}/tags/${encodeURIComponent(tagId)}`,
{ method: 'DELETE' }
);
}
export type { Manga, MangaStatus, Page };