app-template/packages/danmaku/src/client.ts

281 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}