diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts new file mode 100644 index 0000000..f0ac4f4 --- /dev/null +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { generateOpenSignature, headMd5, openCommentsToXml } from "./danmaku.service.js"; + +describe("danmaku open network helpers", () => { + it("headMd5 matches md5 of sample", () => { + const sample = Buffer.from("hello"); + expect(headMd5(sample)).toBe(createHash("md5").update(sample).digest("hex")); + }); + + it("signature is base64(sha256(AppId + Timestamp + Path + AppSecret))", () => { + const sig = generateOpenSignature("app", 1735660800, "/api/v2/comment/1", "sec"); + const expectSig = createHash("sha256") + .update("app" + "1735660800" + "/api/v2/comment/1" + "sec") + .digest("base64"); + expect(sig).toBe(expectSig); + }); + + it("comments p/m maps to bilibili xml", () => { + const xml = openCommentsToXml([ + { p: "12.5,1,16777215", m: "hello" }, + { p: "3,5,16777215", m: "top & " }, + ]); + expect(xml).toContain('hello'); + expect(xml).toContain('top & <b>'); + }); + + it("comments with explicit fields also work", () => { + const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]); + expect(xml).toContain('x'); + }); + + it("skips invalid comments", () => { + expect( + openCommentsToXml([ + { p: "abc,1", m: "x" }, + { p: "1,1", m: "" }, + ]), + ).toBe(''); + }); +}); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index e8ba06f..9c72dec 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -5,17 +5,95 @@ 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"; + 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 文件 hash(首 64KiB + 尺寸),用于开放网络识别 */ -async function remoteSampleHash(userId: string, mediaId: string): Promise { +/** 取 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); @@ -27,7 +105,9 @@ async function remoteSampleHash(userId: string, mediaId: string): Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on("data", (chunk: Buffer) => { @@ -36,11 +116,7 @@ async function remoteSampleHash(userId: string, mediaId: string): Promise resolve(Buffer.concat(chunks))); stream.on("error", reject); }); - return createHash("sha256") - .update(sample) - .update(String(item.size)) - .digest("hex") - .toUpperCase(); + return headMd5(sample.subarray(0, HASH_HEAD_BYTES)); } catch { return null; } @@ -52,28 +128,36 @@ async function fetchOpenNetworkXml( hash: string | null, ): Promise { const base = openApiBase(); + const matchPath = "/api/v2/match"; try { - const identifyRes = await fetch(`${base}/api/v2/identify`, { + const matchRes = await fetch(`${base}${matchPath}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: openHeaders(matchPath), body: JSON.stringify({ + fileName: path.split("/").pop() ?? path, fileHash: hash ?? "", fileSize: size, - fileName: path.split("/").pop() ?? path, + matchMode: hash ? "hashAndFileName" : "fileNameOnly", }), }); - if (!identifyRes.ok) return null; - const identified = (await identifyRes.json()) as { + if (!matchRes.ok) return null; + const matched = (await matchRes.json()) as { isMatched?: boolean; - matched?: boolean; - episodeId?: number; + success?: boolean; + matches?: Array<{ episodeId?: number | string }>; }; - const episodeId = identified.episodeId; - if (!episodeId || !(identified.isMatched || identified.matched)) return null; - const commentRes = await fetch(`${base}/api/v2/comment/${episodeId}?withResponses=false`); + 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 { danmaku?: string; xml?: string }; - return body.danmaku ?? body.xml ?? null; + const body = (await commentRes.json()) as { comments?: OpenComment[] }; + const comments = body.comments ?? []; + return openCommentsToXml(comments); } catch { return null; } @@ -84,15 +168,19 @@ export const danmakuService = { 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 remoteSampleHash(userId, mediaItemId); + const hash = await remoteFileHash(userId, mediaItemId); const xml = await fetchOpenNetworkXml(item.path, item.size, hash); - if (!xml) { + if (xml === null) { return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; } await danmakuCacheDao.upsert({