feat(plugins): mangabaka

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:48:39 +01:00
co-authored by Claude Opus 5.5
parent 06a59e1b5c
commit ddd938935e
10 changed files with 273 additions and 0 deletions
+46
View File
@@ -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 };
}
+14
View File
@@ -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));
}
+75
View File
@@ -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 && 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", 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<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 },
];
}
+32
View File
@@ -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<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" || !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;
}
}