@@ -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,
|
||||
});
|
||||
@@ -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<string, string> {
|
||||
const token = ctx.config.token;
|
||||
if (typeof token !== "string" || token === "") throw new Error("mangabaka: token is not configured");
|
||||
return { "x-api-key": token };
|
||||
}
|
||||
@@ -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<WorkHint[]> {
|
||||
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));
|
||||
}
|
||||
@@ -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<string, Status> = {
|
||||
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 && Object.hasOwn(STATE_MAP, s) ? STATE_MAP[s]! : "none";
|
||||
}
|
||||
|
||||
function hint(e: ApiEntry): WorkHint {
|
||||
const s = e.series;
|
||||
const externalIds: Record<string, string> = { 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_manga", s?.source?.anilist);
|
||||
add("mal_manga", s?.source?.my_anime_list);
|
||||
add("mangaupdates", s?.source?.manga_updates);
|
||||
add("kitsu_manga", 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<string, number> = {};
|
||||
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 },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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<SourceRecord> {
|
||||
const headers = authHeaders(ctx);
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await ctx.http.json<LibraryPage>(`${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<SourceRecord> {
|
||||
const headers = authHeaders(ctx);
|
||||
let cursor = (await ctx.cursor.get()) ?? undefined;
|
||||
for (;;) {
|
||||
const res = await ctx.http.json<ChangesPage>(`${BASE_URL}/v2/my/library/changes`, { headers, query: { cursor }, signal: ctx.signal });
|
||||
for (const c of res.data) {
|
||||
if (c.type === "delete") yield* mapDeletion(c.series_id);
|
||||
else if (c.entry) yield* mapEntry(c.entry);
|
||||
else ctx.log(`mangabaka: upsert without entry for series ${c.series_id}, skipped`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"data":[{"id":5,"title":"Delta","type":"manga","state":"active","merged_with":null,"year":2018,"source":{"anilist":{"id":55}}}]}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 { mapDeletion, 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<string, unknown>) => {
|
||||
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_manga: "11", mal_manga: "22", mangaupdates: "33", kitsu_manga: "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("upsert without entry is skipped and logged, never deleted", async () => {
|
||||
const logs: string[] = [];
|
||||
const h = createFixtureHttp({
|
||||
[`GET ${B}/v2/my/library/changes`]: (req: Request) =>
|
||||
new URL(req.url).searchParams.has("cursor") ? { data: [], next_cursor: "c" } : { data: [{ type: "upsert", series_id: 4 }], next_cursor: "c" },
|
||||
});
|
||||
const ctx = createTestContext({ fetch: h.fetch, config: { token: "tok" }, log: (m) => logs.push(m) });
|
||||
const recs = await collect(plugin.pullChanges!(ctx));
|
||||
expect(recs).toEqual([]);
|
||||
expect(logs).toEqual(["mangabaka: upsert without entry for series 4, skipped"]);
|
||||
});
|
||||
|
||||
test("inherited keys are not states", () => expect(mapState("constructor")).toBe("none"));
|
||||
|
||||
test("delete keys are a subset of keys a full pull produced", async () => {
|
||||
const { ctx } = ctxFor({ [`GET ${B}/v1/my/library?page=1`]: page1, [`GET ${B}/v1/my/library?page=2`]: page2 });
|
||||
const pulled = (await collect(plugin.pull!(ctx))).map((r) => r.key);
|
||||
const del = mapDeletion(1).map((r) => r.key);
|
||||
expect(del).toEqual(["series:1", "progress:1"]);
|
||||
for (const k of del) expect(pulled).toContain(k);
|
||||
});
|
||||
|
||||
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_manga: "55" } }]);
|
||||
expect(new URL(h.calls[0]!.url).searchParams.get("q")).toBe("Delta");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user