203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
|
||
import type { DanmakuFetchOutput } from "@app/types";
|
||
import { decryptSecret } from "./secret.js";
|
||
import { createWebdav } from "./webdav-client.js";
|
||
|
||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||
/** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */
|
||
const HASH_HEAD_BYTES = 16 * 1024 * 1024;
|
||
|
||
type OpenComment = {
|
||
p?: string;
|
||
m?: string;
|
||
text?: string;
|
||
mode?: number;
|
||
time?: number;
|
||
};
|
||
|
||
function openApiBase(): string {
|
||
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
|
||
/\/+$/,
|
||
"",
|
||
);
|
||
}
|
||
|
||
function openCredentials(): { appId: string; appSecret: string } | null {
|
||
const appId = process.env["OPEN_DANMAKU_APP_ID"];
|
||
const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"];
|
||
if (!appId || !appSecret) return null;
|
||
return { appId, appSecret };
|
||
}
|
||
|
||
/** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */
|
||
export function generateOpenSignature(
|
||
appId: string,
|
||
timestamp: number,
|
||
path: string,
|
||
appSecret: string,
|
||
): string {
|
||
return createHash("sha256").update(`${appId}${timestamp}${path}${appSecret}`).digest("base64");
|
||
}
|
||
|
||
/** 文件头部 MD5(样本应已截到 16MB 内) */
|
||
export function headMd5(sample: Buffer): string {
|
||
return createHash("md5").update(sample).digest("hex");
|
||
}
|
||
|
||
function escapeXml(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,0,0">text</d>`) */
|
||
export function openCommentsToXml(comments: OpenComment[]): string {
|
||
const nodes: string[] = [];
|
||
for (const c of comments) {
|
||
let time = Number.NaN;
|
||
let mode = 1;
|
||
let text = "";
|
||
if (typeof c.p === "string") {
|
||
const [t, m] = c.p.split(",");
|
||
time = Number(t);
|
||
mode = Number(m) || 1;
|
||
text = c.m ?? "";
|
||
} else {
|
||
time = Number(c.time);
|
||
mode = Number(c.mode) || 1;
|
||
text = c.text ?? c.m ?? "";
|
||
}
|
||
if (!Number.isFinite(time) || !text) continue;
|
||
nodes.push(`<d p="${time},${mode},0,0">${escapeXml(text)}</d>`);
|
||
}
|
||
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
|
||
}
|
||
|
||
function openHeaders(path: string): Record<string, string> {
|
||
const creds = openCredentials();
|
||
if (!creds) return {};
|
||
const timestamp = Math.floor(Date.now() / 1000);
|
||
return {
|
||
"Content-Type": "application/json",
|
||
"X-AppId": creds.appId,
|
||
"X-Timestamp": String(timestamp),
|
||
"X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret),
|
||
};
|
||
}
|
||
|
||
function matchKeyFor(path: string, size: number): string {
|
||
return createHash("sha256").update(`${path}:${size}`).digest("hex");
|
||
}
|
||
|
||
/** 取 WebDAV 文件前 16MB 的 MD5(开放网络文件识别用) */
|
||
async function remoteFileHash(userId: string, mediaId: string): Promise<string | null> {
|
||
const item = await mediaItemDao.getByIdForUser(mediaId, userId);
|
||
if (!item?.mountId) return null;
|
||
const mount = await mountDao.getByIdForUser(item.mountId, userId);
|
||
if (!mount) return null;
|
||
const client = createWebdav({
|
||
baseUrl: mount.baseUrl,
|
||
username: mount.username ?? undefined,
|
||
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
|
||
});
|
||
try {
|
||
const abs = mount.rootPath.replace(/\/+$/, "") + item.path;
|
||
const stream = client.createReadStream(abs, {
|
||
range: { start: 0, end: HASH_HEAD_BYTES - 1 },
|
||
});
|
||
const sample = await new Promise<Buffer>((resolve, reject) => {
|
||
const chunks: Buffer[] = [];
|
||
stream.on("data", (chunk: Buffer) => {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||
});
|
||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||
stream.on("error", reject);
|
||
});
|
||
return headMd5(sample.subarray(0, HASH_HEAD_BYTES));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchOpenNetworkXml(
|
||
path: string,
|
||
size: number,
|
||
hash: string | null,
|
||
): Promise<string | null> {
|
||
const base = openApiBase();
|
||
const matchPath = "/api/v2/match";
|
||
try {
|
||
const matchRes = await fetch(`${base}${matchPath}`, {
|
||
method: "POST",
|
||
headers: openHeaders(matchPath),
|
||
body: JSON.stringify({
|
||
fileName: path.split("/").pop() ?? path,
|
||
fileHash: hash ?? "",
|
||
fileSize: size,
|
||
matchMode: hash ? "hashAndFileName" : "fileNameOnly",
|
||
}),
|
||
});
|
||
if (!matchRes.ok) return null;
|
||
const matched = (await matchRes.json()) as {
|
||
isMatched?: boolean;
|
||
success?: boolean;
|
||
matches?: Array<{ episodeId?: number | string }>;
|
||
};
|
||
if (matched.success === false) return null;
|
||
const episodeId = matched.matches?.[0]?.episodeId;
|
||
if (!episodeId || !matched.isMatched) return null;
|
||
|
||
const commentPath = `/api/v2/comment/${episodeId}`;
|
||
const commentRes = await fetch(`${base}${commentPath}?withRelated=true`, {
|
||
headers: openHeaders(commentPath),
|
||
});
|
||
if (!commentRes.ok) return null;
|
||
const body = (await commentRes.json()) as { comments?: OpenComment[] };
|
||
const comments = body.comments ?? [];
|
||
return openCommentsToXml(comments);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export const danmakuService = {
|
||
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||
|
||
if (!openCredentials()) {
|
||
return { ok: true, source: "none", xml: "", message: "未配置开放网络" };
|
||
}
|
||
|
||
const matchKey = matchKeyFor(item.path, item.size);
|
||
const cached = await danmakuCacheDao.getValid(matchKey);
|
||
if (cached) {
|
||
return { ok: true, source: "cache", xml: cached.payload };
|
||
}
|
||
|
||
const hash = await remoteFileHash(userId, mediaItemId);
|
||
const xml = await fetchOpenNetworkXml(item.path, item.size, hash);
|
||
if (xml === null) {
|
||
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||
}
|
||
await danmakuCacheDao.upsert({
|
||
matchKey,
|
||
payload: xml,
|
||
source: "open-network",
|
||
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
||
});
|
||
return { ok: true, source: "open-network", xml };
|
||
},
|
||
|
||
/** 本地 XML 由前端解析后直接喂播放器;此处仅确认媒体存在并记 size 日志约束 */
|
||
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
|
||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||
if (!item) return Promise.reject(new Error("媒体不存在"));
|
||
if (byteSize > 20_000_000) return Promise.reject(new Error("XML 过大"));
|
||
return { ok: true };
|
||
},
|
||
};
|