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, """);
}
/** 开放网络 comments → Bilibili 式 XML(`text`) */
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(`${escapeXml(text)}`);
}
return `${nodes.join("")}`;
}
function openHeaders(path: string): Record {
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 {
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((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 {
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 {
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 };
},
};