From ddd938935e996df4728281facca0aa63cf953530 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:48:39 +0100 Subject: [PATCH] feat(plugins): mangabaka Co-Authored-By: Claude Opus 5.5 --- plugins/mangabaka/index.ts | 19 +++++ plugins/mangabaka/src/api.ts | 46 ++++++++++++ plugins/mangabaka/src/lookup.ts | 14 ++++ plugins/mangabaka/src/map.ts | 75 +++++++++++++++++++ plugins/mangabaka/src/pull.ts | 32 ++++++++ plugins/mangabaka/test/fixtures/changes.json | 4 + .../test/fixtures/library-page1.json | 4 + .../test/fixtures/library-page2.json | 3 + plugins/mangabaka/test/fixtures/search.json | 1 + plugins/mangabaka/test/mangabaka.test.ts | 75 +++++++++++++++++++ 10 files changed, 273 insertions(+) create mode 100644 plugins/mangabaka/index.ts create mode 100644 plugins/mangabaka/src/api.ts create mode 100644 plugins/mangabaka/src/lookup.ts create mode 100644 plugins/mangabaka/src/map.ts create mode 100644 plugins/mangabaka/src/pull.ts create mode 100644 plugins/mangabaka/test/fixtures/changes.json create mode 100644 plugins/mangabaka/test/fixtures/library-page1.json create mode 100644 plugins/mangabaka/test/fixtures/library-page2.json create mode 100644 plugins/mangabaka/test/fixtures/search.json create mode 100644 plugins/mangabaka/test/mangabaka.test.ts diff --git a/plugins/mangabaka/index.ts b/plugins/mangabaka/index.ts new file mode 100644 index 0000000..45e59c4 --- /dev/null +++ b/plugins/mangabaka/index.ts @@ -0,0 +1,19 @@ +import { definePlugin } from "@sttracker/plugin-sdk/types"; +import { lookup } from "./src/lookup.ts"; +import { pull, pullChanges } from "./src/pull.ts"; + +export default definePlugin({ + manifest: { + id: "mangabaka", + name: "MangaBaka", + version: "0.1.0", + sdkVersion: 1, + mediaTypes: ["manga", "comic"], + capabilities: ["pull", "pullChanges", "lookup"], + config: [{ key: "token", label: "API token", type: "secret", required: true, help: "Sent as the x-api-key header." }], + attribution: "Manga data from MangaBaka (CC BY-NC-SA 4.0).", + }, + pull, + pullChanges, + lookup, +}); diff --git a/plugins/mangabaka/src/api.ts b/plugins/mangabaka/src/api.ts new file mode 100644 index 0000000..5707c33 --- /dev/null +++ b/plugins/mangabaka/src/api.ts @@ -0,0 +1,46 @@ +import type { PluginContext } from "@sttracker/plugin-sdk/types"; + +export const BASE_URL = "https://api.mangabaka.org"; +/** The host builds the HttpClient; it should use this rate for this plugin. */ +export const RATE_PER_MINUTE = 150; + +export interface ApiSource { id?: number | string | null } +export interface ApiSeries { + id: number; + title: string; + type?: string | null; + state?: string | null; + merged_with?: number | null; + year?: number | null; + source?: { + anilist?: ApiSource | null; + my_anime_list?: ApiSource | null; + manga_updates?: ApiSource | null; + kitsu?: ApiSource | null; + } | null; +} +export interface ApiEntry { + series_id: number; + state?: string | null; + rating?: number | null; + progress_chapter?: number | null; + progress_volume?: number | null; + start_date?: string | null; + finish_date?: string | null; + note?: string | null; + series?: ApiSeries | null; +} +export interface LibraryPage { + data: ApiEntry[]; + pagination?: { page?: number; total_pages?: number } | null; +} +export interface ChangesPage { + data: { type: "upsert" | "delete"; entry?: ApiEntry; series_id: number }[]; + next_cursor?: string | null; +} + +export function authHeaders(ctx: PluginContext): Record { + const token = ctx.config.token; + if (typeof token !== "string" || token === "") throw new Error("mangabaka: token is not configured"); + return { "x-api-key": token }; +} diff --git a/plugins/mangabaka/src/lookup.ts b/plugins/mangabaka/src/lookup.ts new file mode 100644 index 0000000..95e1fc5 --- /dev/null +++ b/plugins/mangabaka/src/lookup.ts @@ -0,0 +1,14 @@ +import type { MediaType, PluginContext, WorkHint } from "@sttracker/plugin-sdk/types"; +import { BASE_URL, authHeaders, type ApiSeries } from "./api.ts"; +import { mapEntry } from "./map.ts"; + +export async function lookup(ctx: PluginContext, q: { title: string; mediaType?: MediaType; year?: number }): Promise { + const res = await ctx.http.json<{ data: ApiSeries[] }>(`${BASE_URL}/v1/series/search`, { + headers: authHeaders(ctx), + query: { q: q.title }, + signal: ctx.signal, + }); + return res.data + .map((s) => (mapEntry({ series_id: s.id, series: s })[0] as { work: WorkHint }).work) + .filter((w) => (q.mediaType ? w.mediaType === q.mediaType : true) && (q.year == null || w.year == null || w.year === q.year)); +} diff --git a/plugins/mangabaka/src/map.ts b/plugins/mangabaka/src/map.ts new file mode 100644 index 0000000..1041eff --- /dev/null +++ b/plugins/mangabaka/src/map.ts @@ -0,0 +1,75 @@ +import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date"; +import type { SourceRecord, Status, WorkHint } from "@sttracker/plugin-sdk/types"; +import type { ApiEntry } from "./api.ts"; + +export const STATE_MAP: Record = { + considering: "planned", + plan_to_read: "planned", + reading: "in_progress", + rereading: "in_progress", + paused: "paused", + dropped: "dropped", + completed: "completed", +}; + +export function mapState(s: string | null | undefined): Status { + return (s != null && STATE_MAP[s]) || "none"; +} + +function hint(e: ApiEntry): WorkHint { + const s = e.series; + const externalIds: Record = { mangabaka: String(e.series_id) }; + const add = (ns: string, v: { id?: number | string | null } | null | undefined) => { + if (v?.id != null && v.id !== "") externalIds[ns] = String(v.id); + }; + add("anilist", s?.source?.anilist); + add("mal", s?.source?.my_anime_list); + add("mangaupdates", s?.source?.manga_updates); + add("kitsu", s?.source?.kitsu); + return { + title: s?.title ?? `MangaBaka series ${e.series_id}`, + mediaType: s?.type === "comic" ? "comic" : "manga", + ...(s?.year != null ? { year: s.year } : {}), + externalIds, + }; +} + +export function mapEntry(e: ApiEntry): SourceRecord[] { + const work = hint(e); + const key = `series:${e.series_id}`; + const merged = e.series?.state === "merged" && e.series.merged_with != null; + const entry: SourceRecord = { + kind: "entry", + key, + raw: e, + work, + entry: { + status: mapState(e.state), + ...(e.rating !== undefined ? { rating: e.rating } : {}), + ...(e.note !== undefined ? { notes: e.note } : {}), + }, + ...(merged ? { mergedIntoKey: `series:${e.series!.merged_with}` } : {}), + }; + const progress: Record = {}; + if (e.progress_chapter != null) progress.chapter = e.progress_chapter; + if (e.progress_volume != null) progress.volume = e.progress_volume; + const started = parsePartialDate(e.start_date); + const finished = parsePartialDate(e.finish_date); + if (Object.keys(progress).length === 0 && !started && !finished) return [entry]; + const engagement: SourceRecord = { + kind: "engagement", + key: `progress:${e.series_id}`, + raw: e, + work, + engagement: { progress, startedAt: started, finishedAt: finished }, + }; + return [entry, engagement]; +} + +export function mapDeletion(seriesId: number): SourceRecord[] { + const work: WorkHint = { title: `MangaBaka series ${seriesId}`, mediaType: "manga", externalIds: { mangabaka: String(seriesId) } }; + return [ + { kind: "entry", key: `series:${seriesId}`, raw: { series_id: seriesId }, work, entry: {}, deleted: true }, + { kind: "engagement", key: `progress:${seriesId}`, raw: { series_id: seriesId }, work, engagement: {}, deleted: true }, + ]; +} diff --git a/plugins/mangabaka/src/pull.ts b/plugins/mangabaka/src/pull.ts new file mode 100644 index 0000000..0f588ae --- /dev/null +++ b/plugins/mangabaka/src/pull.ts @@ -0,0 +1,32 @@ +import type { PluginContext, SourceRecord } from "@sttracker/plugin-sdk/types"; +import { BASE_URL, authHeaders, type ChangesPage, type LibraryPage } from "./api.ts"; +import { mapDeletion, mapEntry } from "./map.ts"; + +export async function* pull(ctx: PluginContext): AsyncIterable { + const headers = authHeaders(ctx); + for (let page = 1; ; page++) { + const res = await ctx.http.json(`${BASE_URL}/v1/my/library`, { headers, query: { page }, signal: ctx.signal }); + for (const e of res.data) yield* mapEntry(e); + const total = res.pagination?.total_pages; + if (res.data.length === 0 || (total != null && page >= total)) return; + } +} + +export async function* pullChanges(ctx: PluginContext): AsyncIterable { + const headers = authHeaders(ctx); + let cursor = (await ctx.cursor.get()) ?? undefined; + for (;;) { + const res = await ctx.http.json(`${BASE_URL}/v2/my/library/changes`, { headers, query: { cursor }, signal: ctx.signal }); + for (const c of res.data) { + if (c.type === "delete" || !c.entry) yield* mapDeletion(c.series_id); + else yield* mapEntry(c.entry); + } + const next = res.next_cursor; + if (next) { + await ctx.cursor.set(next); + if (next === cursor) return; + cursor = next; + } + if (!next || res.data.length === 0) return; + } +} diff --git a/plugins/mangabaka/test/fixtures/changes.json b/plugins/mangabaka/test/fixtures/changes.json new file mode 100644 index 0000000..69bdbec --- /dev/null +++ b/plugins/mangabaka/test/fixtures/changes.json @@ -0,0 +1,4 @@ +{"data":[ +{"type":"upsert","series_id":1,"entry":{"series_id":1,"state":"paused","rating":70,"progress_chapter":13,"progress_volume":2,"start_date":"2024-03-05","finish_date":null,"note":null,"series":{"id":1,"title":"Alpha","type":"manga","state":"active","merged_with":null,"year":2020,"source":{}}}}, +{"type":"delete","series_id":7} +],"next_cursor":"cur-2"} diff --git a/plugins/mangabaka/test/fixtures/library-page1.json b/plugins/mangabaka/test/fixtures/library-page1.json new file mode 100644 index 0000000..99b2cd9 --- /dev/null +++ b/plugins/mangabaka/test/fixtures/library-page1.json @@ -0,0 +1,4 @@ +{"data":[ +{"series_id":1,"state":"reading","rating":80,"progress_chapter":12,"progress_volume":2,"start_date":"2024-03-05","finish_date":null,"note":"good","series":{"id":1,"title":"Alpha","type":"manga","state":"active","merged_with":null,"year":2020,"source":{"anilist":{"id":11},"my_anime_list":{"id":22},"manga_updates":{"id":33},"kitsu":{"id":44}}}}, +{"series_id":2,"state":"plan_to_read","rating":null,"progress_chapter":null,"progress_volume":null,"start_date":null,"finish_date":null,"note":null,"series":{"id":2,"title":"Beta","type":"manga","state":"merged","merged_with":9,"year":null,"source":{}}} +],"pagination":{"page":1,"total_pages":2}} diff --git a/plugins/mangabaka/test/fixtures/library-page2.json b/plugins/mangabaka/test/fixtures/library-page2.json new file mode 100644 index 0000000..003b028 --- /dev/null +++ b/plugins/mangabaka/test/fixtures/library-page2.json @@ -0,0 +1,3 @@ +{"data":[ +{"series_id":3,"state":"completed","rating":95,"progress_chapter":50,"progress_volume":null,"start_date":"2023-01","finish_date":"2023-06-30","note":null,"series":{"id":3,"title":"Gamma","type":"manga","state":"active","merged_with":null,"year":2019,"source":{}}} +],"pagination":{"page":2,"total_pages":2}} diff --git a/plugins/mangabaka/test/fixtures/search.json b/plugins/mangabaka/test/fixtures/search.json new file mode 100644 index 0000000..ebeb7ce --- /dev/null +++ b/plugins/mangabaka/test/fixtures/search.json @@ -0,0 +1 @@ +{"data":[{"id":5,"title":"Delta","type":"manga","state":"active","merged_with":null,"year":2018,"source":{"anilist":{"id":55}}}]} diff --git a/plugins/mangabaka/test/mangabaka.test.ts b/plugins/mangabaka/test/mangabaka.test.ts new file mode 100644 index 0000000..18446ea --- /dev/null +++ b/plugins/mangabaka/test/mangabaka.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing"; +import type { Status } from "@sttracker/plugin-sdk/types"; +import plugin from "../index.ts"; +import { mapState } from "../src/map.ts"; +import page1 from "./fixtures/library-page1.json"; +import page2 from "./fixtures/library-page2.json"; +import changes from "./fixtures/changes.json"; +import search from "./fixtures/search.json"; + +const B = "https://api.mangabaka.org"; +const ctxFor = (routes: Record) => { + const h = createFixtureHttp(routes); + return { h, ctx: createTestContext({ fetch: h.fetch, config: { token: "tok" } }) }; +}; + +describe("mangabaka", () => { + test("manifest", () => { + expect(plugin.manifest.id).toBe("mangabaka"); + expect(plugin.manifest.capabilities).toEqual(["pull", "pullChanges", "lookup"]); + }); + + test.each<[string, Status]>([ + ["considering", "planned"], ["plan_to_read", "planned"], ["reading", "in_progress"], + ["rereading", "in_progress"], ["paused", "paused"], ["dropped", "dropped"], ["completed", "completed"], + ])("state %s -> %s", (a, b) => expect(mapState(a)).toBe(b)); + + test("pull pages, maps entries and engagements, sends api key", async () => { + const { h, ctx } = ctxFor({ + [`GET ${B}/v1/my/library?page=1`]: page1, + [`GET ${B}/v1/my/library?page=2`]: page2, + }); + const recs = await collect(plugin.pull!(ctx)); + expect(recs.map((r) => r.key)).toEqual(["series:1", "progress:1", "series:2", "series:3", "progress:3"]); + expect(h.calls.every((c) => c.headers.get("x-api-key") === "tok")).toBe(true); + const a = recs[0]!; + if (a.kind !== "entry") throw new Error(); + expect(a.entry).toEqual({ status: "in_progress", rating: 80, notes: "good" }); + expect(a.work.externalIds).toEqual({ mangabaka: "1", anilist: "11", mal: "22", mangaupdates: "33", kitsu: "44" }); + const p = recs[1]!; + if (p.kind !== "engagement") throw new Error(); + expect(p.engagement.progress).toEqual({ chapter: 12, volume: 2 }); + expect(p.engagement.startedAt).toEqual({ value: "2024-03-05", precision: "day" }); + expect(p.engagement.finishedAt).toBeNull(); + }); + + test("merged series sets mergedIntoKey", async () => { + const { ctx } = ctxFor({ [`GET ${B}/v1/my/library?page=1`]: page1, [`GET ${B}/v1/my/library?page=2`]: page2 }); + const recs = await collect(plugin.pull!(ctx)); + expect(recs.find((r) => r.key === "series:2")!.mergedIntoKey).toBe("series:9"); + expect(recs.find((r) => r.key === "series:1")!.mergedIntoKey).toBeUndefined(); + }); + + test("pullChanges yields upsert and deletion, saves cursor", async () => { + const { h, ctx } = ctxFor({ + [`GET ${B}/v2/my/library/changes`]: (req: Request) => + new URL(req.url).searchParams.get("cursor") === "cur-2" ? { data: [], next_cursor: "cur-2" } : changes, + }); + await ctx.cursor.set("cur-1"); + ctx.saved.cursor.length = 0; + const recs = await collect(plugin.pullChanges!(ctx)); + expect(recs.filter((r) => r.deleted).map((r) => r.key)).toEqual(["series:7", "progress:7"]); + expect(recs[0]!.key).toBe("series:1"); + expect(ctx.saved.cursor).toEqual(["cur-2", "cur-2"]); + expect(await ctx.cursor.get()).toBe("cur-2"); + expect(new URL(h.calls[0]!.url).searchParams.get("cursor")).toBe("cur-1"); + }); + + test("lookup searches series", async () => { + const { h, ctx } = ctxFor({ [`GET ${B}/v1/series/search`]: search }); + const r = await plugin.lookup!(ctx, { title: "Delta", mediaType: "manga" }); + expect(r).toEqual([{ title: "Delta", mediaType: "manga", year: 2018, externalIds: { mangabaka: "5", anilist: "55" } }]); + expect(new URL(h.calls[0]!.url).searchParams.get("q")).toBe("Delta"); + }); +});