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