198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
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<string, string> {
|
||
const headers: Record<string, string> = { "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<OpenMatchOutcome> {
|
||
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<string | null> {
|
||
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
|
||
if (!res.ok) return null;
|
||
const body = res.json as { comments?: Array<Record<string, unknown>> };
|
||
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<OpenSearchOutcome> {
|
||
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<OpenEpisodesOutcome> {
|
||
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) };
|
||
}
|