diff --git a/src/core/mangabaka/client.ts b/src/core/mangabaka/client.ts new file mode 100644 index 0000000..506b116 --- /dev/null +++ b/src/core/mangabaka/client.ts @@ -0,0 +1,114 @@ +import type { Transport } from "../transport"; +import type { + ImageSnapshot, PreviewResult, PublicImage, SaveResult, SeriesLink, SeriesSummary, SubmissionImage, +} from "./types"; + +export const API_BASE = "https://api.mangabaka.org"; +export const SITE_BASE = "https://mangabaka.org"; +export const coverFormUrl = (seriesId: number): string => `${SITE_BASE}/data/submissions/series/${seriesId}/images`; + +export class MangaBakaError extends Error { + constructor(message: string, readonly status: number, readonly body?: unknown) { + super(message); + this.name = "MangaBakaError"; + } +} + +const MAX_PAGES = 50; + +/** Talks to api.mangabaka.org directly (CORS is open); the token never goes anywhere else. */ +export class MangaBakaClient { + constructor(private readonly transport: Transport, private readonly token?: string) {} + + async getSeries(id: number): Promise { + return (await this.get<{ data: SeriesSummary }>(`/v1/series/${id}`)).data; + } + + getLinks(id: number): Promise { + return this.getAllPages(`/v1/series/${id}/links?limit=100`); + } + + /** Paginated, `limit` ≤ 50 (spec §2.3). */ + getImages(id: number): Promise { + return this.getAllPages(`/v1/series/${id}/images?limit=50`); + } + + async getImageSnapshot(id: number): Promise { + const body = await this.get<{ data: { data: { images: SubmissionImage[] }; version: number } }>( + `/v0/my/submissions/series-images/${id}`, true); + return { version: body.data.version, images: body.data.data.images }; + } + + async previewImages(id: number, version: number, images: SubmissionImage[]): Promise { + return (await this.post<{ data: PreviewResult }>(`/v0/my/submissions/series-images/${id}/preview`, + { version, data: { images } })).data; + } + + /** Files a real edit request, always for review. Call only after the user confirmed the preview. */ + async saveImagesForReview(id: number, version: number, images: SubmissionImage[], userNote: string): Promise { + return (await this.post<{ data: SaveResult }>(`/v0/my/submissions/series-images/${id}`, + { version, user_note: userNote, save_mode: "review", data: { images } })).data; + } + + private headers(auth: boolean): Record { + const headers: Record = { accept: "application/json" }; + if (auth) { + if (!this.token) throw new MangaBakaError("This needs a MangaBaka personal access token.", 401); + headers["x-api-key"] = this.token; + } + return headers; + } + + private async get(path: string, auth = false): Promise { + const headers = this.headers(auth); + return this.parse(await this.transport.fetch(`${API_BASE}${path}`, { headers }), path); + } + + private async post(path: string, body: unknown): Promise { + const headers = { ...this.headers(true), "content-type": "application/json" }; + const res = await this.transport.fetch(`${API_BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); + return this.parse(res, path); + } + + private async getAllPages(path: string): Promise { + const out: T[] = []; + let url: string | null = `${API_BASE}${path}`; + for (let page = 0; url && page < MAX_PAGES; page++) { + const res = await this.transport.fetch(url, { headers: this.headers(false) }); + const body = await this.parse<{ data: T[]; pagination?: { next?: string | null } }>(res, path); + out.push(...body.data); + const next = body.pagination?.next ?? null; + url = next && new URL(next).origin === API_BASE ? next : null; + } + return out; + } + + private async parse(res: Response, path: string): Promise { + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + if (!res.ok) throw new MangaBakaError(explain(res.status, body, path), res.status, body); + if (body === undefined) throw new MangaBakaError(`MangaBaka sent something that isn't JSON for ${path}.`, res.status); + return body as T; + } +} + +function explain(status: number, body: unknown, path: string): string { + const detail = body && typeof body === "object" && "message" in body ? String((body as { message: unknown }).message) : ""; + switch (status) { + case 401: + return "MangaBaka rejected the token (401). Check your personal access token."; + case 403: + return "MangaBaka refused (403): the submissions API needs the contributor role or 25 approved submissions. Use the copy-and-paste path instead."; + case 404: + return `MangaBaka has no such series (${path}).`; + case 409: + return "The series' covers changed since they were loaded (409)."; + default: + return `MangaBaka answered HTTP ${status}${detail ? `: ${detail}` : ""}.`; + } +} diff --git a/src/core/mangabaka/types.ts b/src/core/mangabaka/types.ts new file mode 100644 index 0000000..8ff634c --- /dev/null +++ b/src/core/mangabaka/types.ts @@ -0,0 +1,70 @@ +// Subset of https://mangabaka.org/api.json (downloaded 2026-10-14) that we use. See spec §2.3. +import type { ExistingImage } from "../covers"; +import type { ContentRating, ImageType } from "../model"; + +export interface SeriesSummary { + id: number; + title: string; + type: string; + content_rating: string | null; +} + +export interface SeriesLink { + url: string; + name: string; + language: string | null; +} + +/** GET /v1/series/{id}/images item. Has no `is_default`. */ +export interface PublicImage { + id: number; + series_id: number; + type: ImageType; + language: string; + note: string | null; + content_rating: ContentRating; + hashes: { sha256?: string | null } | null; +} + +/** OpenAPI `series-images` item. `work_id`, `note` and `index_numeric` keys are required, even when null. */ +export interface SubmissionImage { + id: number | null; + series_id: number | null; + work_id: string | null; + url: string; + index: string | null; + index_numeric: number | null; + language: string; + type: ImageType; + note: string | null; + content_rating: ContentRating; + is_default: boolean; +} + +export interface ImageSnapshot { + version: number; + images: SubmissionImage[]; +} + +export interface SubmissionChange { + field: string; + type: "added" | "removed" | "changed"; + identity?: string; + label?: string; + details?: Record; +} + +export interface PreviewResult { + has_changes: boolean; + changes: SubmissionChange[]; +} + +export interface SaveResult { + submission_id: number; + status: string; + changes: SubmissionChange[]; +} + +export function toExisting(img: PublicImage): ExistingImage { + return { id: img.id, sha256: img.hashes?.sha256 ?? null, language: img.language, type: img.type }; +} diff --git a/test/mangabaka-client.test.ts b/test/mangabaka-client.test.ts new file mode 100644 index 0000000..97e4028 --- /dev/null +++ b/test/mangabaka-client.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { MangaBakaClient, MangaBakaError, coverFormUrl } from "../src/core/mangabaka/client"; +import { toExisting, type SubmissionImage } from "../src/core/mangabaka/types"; +import { FakeTransport, json } from "./helpers/fake-transport"; +import { fixtureJson } from "./helpers/fixtures"; + +const API = "https://api.mangabaka.org"; + +function publicFake() { + return new FakeTransport() + .on(`${API}/v1/series/9999`, async () => json(await fixtureJson("mangabaka/series-9999.json"))) + .on(`${API}/v1/series/9999/links?limit=100`, async () => json(await fixtureJson("mangabaka/links-9999.json"))) + .on(/\/v1\/series\/9999\/images\?.*page=2/, async () => json(await fixtureJson("mangabaka/images-9999-page2.json"))) + .on(`${API}/v1/series/9999/images?limit=50`, async () => json(await fixtureJson("mangabaka/images-9999-page1.json"))); +} + +describe("public reads (captured fixtures)", () => { + test("series, links, and every page of images", async () => { + const fake = publicFake(); + const mb = new MangaBakaClient(fake); + expect((await mb.getSeries(9999)).title).toBe("Tower of God"); + expect((await mb.getLinks(9999)).map((l) => l.url)).toContain("https://www.webtoons.com/-/-/-/list?title_no=95"); + const images = await mb.getImages(9999); + expect(images).toHaveLength(31); + expect(fake.calls.some((c) => c.init.headers?.["x-api-key"])).toBe(false); + expect(toExisting(images[0]!)).toEqual({ + id: images[0]!.id, sha256: images[0]!.hashes!.sha256 ?? null, language: images[0]!.language, type: images[0]!.type, + }); + }); + + test("a next link to another origin is not followed", async () => { + const fake = new FakeTransport().on(`${API}/v1/series/1/images?limit=50`, () => + json({ status: 200, data: [], pagination: { next: "https://evil.example/page2" } })); + expect(await new MangaBakaClient(fake).getImages(1)).toEqual([]); + expect(fake.calls).toHaveLength(1); + }); + + test("404 is a MangaBakaError", async () => { + const fake = new FakeTransport().on(() => true, () => json({ status: 404, message: "Not found" }, 404)); + const err = await new MangaBakaClient(fake).getSeries(1).catch((e) => e); + expect(err).toBeInstanceOf(MangaBakaError); + expect(err.status).toBe(404); + }); +}); + +// Not capturable without a token: shapes built from the OpenAPI spec (spec §2.3). See docs/TODO.md. +const existing: SubmissionImage = { + id: 7193, series_id: 9999, work_id: null, url: "https://example.org/old.jpg", index: "0.1", index_numeric: 0.1, + language: "ko", type: "volume", note: "Season Cover 3", content_rating: "safe", is_default: true, +}; +const snapshotBody = { status: 200, data: { data: { images: [existing] }, version: 1760000000 } }; +const previewBody = { status: 200, data: { has_changes: true, changes: [{ field: "images", type: "added", identity: "https://x/new.jpg" }] } }; + +describe("submissions (token)", () => { + const SUB = `${API}/v0/my/submissions/series-images/9999`; + + test("snapshot, preview and save send the token and the right bodies", async () => { + const fake = new FakeTransport() + .on((u, i) => u === SUB && (i.method ?? "GET") === "GET", () => json(snapshotBody)) + .on(`${SUB}/preview`, () => json(previewBody)) + .on((u, i) => u === SUB && i.method === "POST", () => + json({ status: 200, data: { submission_id: 5, status: "pending", changes: [] } })); + const mb = new MangaBakaClient(fake, "mb-test-token"); + const snapshot = await mb.getImageSnapshot(9999); + expect(snapshot).toEqual({ version: 1760000000, images: [existing] }); + expect((await mb.previewImages(9999, snapshot.version, snapshot.images)).has_changes).toBe(true); + const saved = await mb.saveImagesForReview(9999, snapshot.version, snapshot.images, "Add WEBTOON covers"); + expect(saved.submission_id).toBe(5); + + expect(fake.calls.every((c) => c.init.headers?.["x-api-key"] === "mb-test-token")).toBe(true); + expect(JSON.parse(fake.calls[1]!.init.body as string)).toEqual({ version: 1760000000, data: { images: [existing] } }); + expect(JSON.parse(fake.calls[2]!.init.body as string)).toEqual({ + version: 1760000000, user_note: "Add WEBTOON covers", save_mode: "review", data: { images: [existing] }, + }); + }); + + test("no token: refuses before sending anything", async () => { + const fake = new FakeTransport(); + await expect(new MangaBakaClient(fake).getImageSnapshot(9999)).rejects.toMatchObject({ status: 401 }); + expect(fake.calls).toHaveLength(0); + }); + + test.each([ + [403, /25 approved submissions/], + [409, /changed since/], + [401, /token/], + ])("HTTP %i explains itself", async (status, message) => { + const fake = new FakeTransport().on(() => true, () => json({ status, message: "x" }, status)); + const err = await new MangaBakaClient(fake, "mb-t").previewImages(9999, 1, []).catch((e) => e); + expect(err).toBeInstanceOf(MangaBakaError); + expect(err.status).toBe(status); + expect(err.message).toMatch(message); + }); +}); + +test("coverFormUrl", () => { + expect(coverFormUrl(9999)).toBe("https://mangabaka.org/data/submissions/series/9999/images"); +});