281 lines
9.6 KiB
TypeScript
281 lines
9.6 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",
|
||
// 登录态存在时附加 Bearer token(可选增强;官方 match/comment 不强制)
|
||
...authHeaders(),
|
||
};
|
||
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;
|
||
episodeNumber: string | null;
|
||
type: string | null;
|
||
typeDescription: string | null;
|
||
};
|
||
|
||
type RawEpisode = {
|
||
episodeId?: number | string;
|
||
episodeTitle?: string | null;
|
||
episodeNumber?: 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,
|
||
episodeNumber: e.episodeNumber ?? 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) };
|
||
}
|
||
|
||
export type OpenBangumiEpisodesOutcome =
|
||
| { ok: true; episodes: OpenEpisodeBrief[] }
|
||
| { ok: false; errorMessage: string; episodes: [] };
|
||
|
||
/** GET /api/v2/bangumi/bgmtv/{subjectId} — Bangumi 作品 → 弹弹play 剧集列表(bgmtv 桥) */
|
||
export async function openBangumiEpisodes(bangumiId: number): Promise<OpenBangumiEpisodesOutcome> {
|
||
const res = await openFetchJson(`/api/v2/bangumi/bgmtv/${bangumiId}`);
|
||
if (!res.ok) return { ok: false, errorMessage: res.error, episodes: [] };
|
||
const body = res.json as { bangumi?: { episodes?: RawEpisode[] } };
|
||
const episodes = (body.bangumi?.episodes ?? [])
|
||
.map(parseEpisode)
|
||
.filter((x): x is OpenEpisodeBrief => x != null);
|
||
return { ok: true, episodes };
|
||
}
|
||
|
||
/** 会话 token(登录弹弹play 后设置;请求附加 Authorization) */
|
||
let sessionToken: string | null = null;
|
||
|
||
export function setSessionToken(token: string | null): void {
|
||
sessionToken = token;
|
||
}
|
||
|
||
export function getSessionToken(): string | null {
|
||
return sessionToken;
|
||
}
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
return sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {};
|
||
}
|
||
|
||
/** POST /api/v2/login — hash=md5(appId+password+timestamp+username+appSecret) */
|
||
export async function loginAccount(
|
||
username: string,
|
||
password: string,
|
||
): Promise<import("./types.js").LoginResult> {
|
||
const creds = effectiveCredentials();
|
||
if (!creds) {
|
||
return { ok: false, token: null, screenName: null, message: "未配置开放弹幕网络" };
|
||
}
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
const hashInput = `${creds.appId}${password}${timestamp}${username}${creds.appSecret}`;
|
||
const { createHash } = await import("node:crypto");
|
||
const hash = createHash("md5").update(hashInput).digest("hex");
|
||
const path = "/api/v2/login";
|
||
const res = await openFetchJson(path, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
userName: username,
|
||
password,
|
||
appId: creds.appId,
|
||
unixTimestamp: timestamp,
|
||
hash,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
return { ok: false, token: null, screenName: null, message: res.error };
|
||
}
|
||
const body = res.json as {
|
||
token?: string | null;
|
||
user?: { screenName?: string | null } | null;
|
||
};
|
||
const token = body.token ?? null;
|
||
if (!token) {
|
||
return { ok: false, token: null, screenName: null, message: "登录响应缺少 token" };
|
||
}
|
||
setSessionToken(token);
|
||
return {
|
||
ok: true,
|
||
token,
|
||
screenName: body.user?.screenName ?? username,
|
||
message: undefined,
|
||
};
|
||
}
|