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

198 lines
6.7 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" };
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) };
}