import { parseMatchOutcome, type OpenMatchOutcome } from "./match.js"; import { generateOpenSignature } from "./signature.js"; import { openCommentsToXml } from "./xml.js"; export type OpenCredentials = { appId: string; appSecret: string }; export function openApiBase(): string { return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace( /\/+$/, "", ); } export function readOpenCredentials(env = process.env): OpenCredentials | null { const appId = env["OPEN_DANMAKU_APP_ID"]; const appSecret = env["OPEN_DANMAKU_APP_SECRET"]; if (!appId || !appSecret) return null; return { appId, appSecret }; } export function hasOpenCredentials(): boolean { return effectiveCredentials() !== null; } /** 运行时覆盖凭证(管理员在设置里配置);null 表示回退环境变量 */ let runtimeCredentials: OpenCredentials | null = null; export function setOpenCredentials(creds: OpenCredentials | null): void { runtimeCredentials = creds; } export function effectiveCredentials(): OpenCredentials | null { return runtimeCredentials ?? readOpenCredentials(); } export function openHeaders(path: string, creds: OpenCredentials | null): Record { const headers: Record = { "Content-Type": "application/json" }; if (!creds) return headers; const timestamp = Math.floor(Date.now() / 1000); return { ...headers, "X-AppId": creds.appId, "X-Timestamp": String(timestamp), "X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret), }; } export async function openFetchJson( path: string, init?: { method?: "POST"; body?: string } | undefined, ): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { const creds = effectiveCredentials(); try { const res = await fetch(`${openApiBase()}${path}`, { method: init?.method ?? "GET", headers: openHeaders(path, creds), ...(init?.body !== undefined ? { body: init.body } : {}), }); if (!res.ok) return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` }; const json: unknown = await res.json(); const base = json as { success?: boolean; errorCode?: number; errorMessage?: string | null; errorDetail?: string | null; }; if (base.success === false || (base.errorCode ?? 0) !== 0) { return { ok: false, status: res.status, error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败", }; } return { ok: true, json }; } catch (e) { return { ok: false, status: null, error: e instanceof Error ? e.message : "弹弹play API 网络错误", }; } } export async function openMatch( fileName: string, fileSize: number, fileHash: string | null, ): Promise { const res = await openFetchJson("/api/v2/match", { method: "POST", body: JSON.stringify({ fileName, fileHash: fileHash ?? "", fileSize, matchMode: fileHash ? "hashAndFileName" : "fileNameOnly", }), }); if (!res.ok) return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] }; return { ok: true, errorMessage: null, ...parseMatchOutcome(res.json) }; } export async function openCommentXml(episodeId: number): Promise { const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`); if (!res.ok) return null; const body = res.json as { comments?: Array> }; return openCommentsToXml(body.comments ?? []); } export type OpenAnimeBrief = { animeId: number; animeTitle: string | null; type: string | null; typeDescription: string | null; imageUrl: string | null; episodes: OpenEpisodeBrief[]; }; export type OpenEpisodeBrief = { episodeId: number; episodeTitle: string | null; type: string | null; typeDescription: string | null; }; type RawEpisode = { episodeId?: number | string; episodeTitle?: string | null; type?: string | null; typeDescription?: string | null; }; type RawAnime = { animeId?: number | string; animeTitle?: string | null; type?: string | null; typeDescription?: string | null; imageUrl?: string | null; episodes?: RawEpisode[]; }; function num(v: number | string | undefined): number | null { const n = typeof v === "number" ? v : Number.parseInt(String(v ?? ""), 10); return Number.isFinite(n) ? n : null; } function parseEpisode(e: RawEpisode): OpenEpisodeBrief | null { const episodeId = num(e.episodeId); if (episodeId == null) return null; return { episodeId, episodeTitle: e.episodeTitle ?? null, type: e.type ?? null, typeDescription: e.typeDescription ?? null, }; } function parseAnime(a: RawAnime): OpenAnimeBrief | null { const animeId = num(a.animeId); if (animeId == null) return null; return { animeId, animeTitle: a.animeTitle ?? null, type: a.type ?? null, typeDescription: a.typeDescription ?? null, imageUrl: a.imageUrl ?? null, episodes: (a.episodes ?? []).map(parseEpisode).filter((x): x is OpenEpisodeBrief => x != null), }; } export type OpenSearchOutcome = | { ok: true; animes: OpenAnimeBrief[] } | { ok: false; errorMessage: string; animes: [] }; /** GET /api/v2/search/episodes?keyword= — 按关键词搜作品(含集列表) */ export async function openSearchEpisodes(keyword: string): Promise { const q = encodeURIComponent(keyword.trim()); const res = await openFetchJson(`/api/v2/search/episodes?keyword=${q}`); if (!res.ok) return { ok: false, errorMessage: res.error, animes: [] }; const body = res.json as { animes?: RawAnime[]; errorCode?: number }; const animes = (body.animes ?? []) .map(parseAnime) .filter((x): x is OpenAnimeBrief => x != null); return { ok: true, animes }; } export type OpenEpisodesOutcome = | { ok: true; anime: OpenAnimeBrief | null } | { ok: false; errorMessage: string; anime: null }; /** GET /api/v2/episodes/{animeId} — 作品下的集列表 */ export async function openAnimeEpisodes(animeId: number): Promise { const res = await openFetchJson(`/api/v2/episodes/${animeId}`); if (!res.ok) return { ok: false, errorMessage: res.error, anime: null }; const body = res.json as RawAnime; return { ok: true, anime: parseAnime(body) }; }