fix(trpc): open danmaku official API match/comment + 16MB MD5
This commit is contained in:
parent
728a576a4f
commit
d9ebcb10bd
|
|
@ -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 & <b>" },
|
||||||
|
]);
|
||||||
|
expect(xml).toContain('<d p="12.5,1,0,0">hello</d>');
|
||||||
|
expect(xml).toContain('<d p="3,5,0,0">top & <b></d>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comments with explicit fields also work", () => {
|
||||||
|
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
|
||||||
|
expect(xml).toContain('<d p="1.25,4,0,0">x</d>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips invalid comments", () => {
|
||||||
|
expect(
|
||||||
|
openCommentsToXml([
|
||||||
|
{ p: "abc,1", m: "x" },
|
||||||
|
{ p: "1,1", m: "" },
|
||||||
|
]),
|
||||||
|
).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,17 +5,95 @@ import { decryptSecret } from "./secret.js";
|
||||||
import { createWebdav } from "./webdav-client.js";
|
import { createWebdav } from "./webdav-client.js";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
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 {
|
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, ">")
|
||||||
|
.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 {
|
function matchKeyFor(path: string, size: number): string {
|
||||||
return createHash("sha256").update(`${path}:${size}`).digest("hex");
|
return createHash("sha256").update(`${path}:${size}`).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取 WebDAV 文件 hash(首 64KiB + 尺寸),用于开放网络识别 */
|
/** 取 WebDAV 文件前 16MB 的 MD5(开放网络文件识别用) */
|
||||||
async function remoteSampleHash(userId: string, mediaId: string): Promise<string | null> {
|
async function remoteFileHash(userId: string, mediaId: string): Promise<string | null> {
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaId, userId);
|
const item = await mediaItemDao.getByIdForUser(mediaId, userId);
|
||||||
if (!item?.mountId) return null;
|
if (!item?.mountId) return null;
|
||||||
const mount = await mountDao.getByIdForUser(item.mountId, userId);
|
const mount = await mountDao.getByIdForUser(item.mountId, userId);
|
||||||
|
|
@ -27,7 +105,9 @@ async function remoteSampleHash(userId: string, mediaId: string): Promise<string
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const abs = mount.rootPath.replace(/\/+$/, "") + item.path;
|
const abs = mount.rootPath.replace(/\/+$/, "") + item.path;
|
||||||
const stream = client.createReadStream(abs, { range: { start: 0, end: 65535 } });
|
const stream = client.createReadStream(abs, {
|
||||||
|
range: { start: 0, end: HASH_HEAD_BYTES - 1 },
|
||||||
|
});
|
||||||
const sample = await new Promise<Buffer>((resolve, reject) => {
|
const sample = await new Promise<Buffer>((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
stream.on("data", (chunk: Buffer) => {
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
|
@ -36,11 +116,7 @@ async function remoteSampleHash(userId: string, mediaId: string): Promise<string
|
||||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
stream.on("error", reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
return createHash("sha256")
|
return headMd5(sample.subarray(0, HASH_HEAD_BYTES));
|
||||||
.update(sample)
|
|
||||||
.update(String(item.size))
|
|
||||||
.digest("hex")
|
|
||||||
.toUpperCase();
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -52,28 +128,36 @@ async function fetchOpenNetworkXml(
|
||||||
hash: string | null,
|
hash: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const base = openApiBase();
|
const base = openApiBase();
|
||||||
|
const matchPath = "/api/v2/match";
|
||||||
try {
|
try {
|
||||||
const identifyRes = await fetch(`${base}/api/v2/identify`, {
|
const matchRes = await fetch(`${base}${matchPath}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: openHeaders(matchPath),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
fileName: path.split("/").pop() ?? path,
|
||||||
fileHash: hash ?? "",
|
fileHash: hash ?? "",
|
||||||
fileSize: size,
|
fileSize: size,
|
||||||
fileName: path.split("/").pop() ?? path,
|
matchMode: hash ? "hashAndFileName" : "fileNameOnly",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!identifyRes.ok) return null;
|
if (!matchRes.ok) return null;
|
||||||
const identified = (await identifyRes.json()) as {
|
const matched = (await matchRes.json()) as {
|
||||||
isMatched?: boolean;
|
isMatched?: boolean;
|
||||||
matched?: boolean;
|
success?: boolean;
|
||||||
episodeId?: number;
|
matches?: Array<{ episodeId?: number | string }>;
|
||||||
};
|
};
|
||||||
const episodeId = identified.episodeId;
|
if (matched.success === false) return null;
|
||||||
if (!episodeId || !(identified.isMatched || identified.matched)) return null;
|
const episodeId = matched.matches?.[0]?.episodeId;
|
||||||
const commentRes = await fetch(`${base}/api/v2/comment/${episodeId}?withResponses=false`);
|
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;
|
if (!commentRes.ok) return null;
|
||||||
const body = (await commentRes.json()) as { danmaku?: string; xml?: string };
|
const body = (await commentRes.json()) as { comments?: OpenComment[] };
|
||||||
return body.danmaku ?? body.xml ?? null;
|
const comments = body.comments ?? [];
|
||||||
|
return openCommentsToXml(comments);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -84,15 +168,19 @@ export const danmakuService = {
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
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 matchKey = matchKeyFor(item.path, item.size);
|
||||||
const cached = await danmakuCacheDao.getValid(matchKey);
|
const cached = await danmakuCacheDao.getValid(matchKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return { ok: true, source: "cache", xml: cached.payload };
|
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);
|
const xml = await fetchOpenNetworkXml(item.path, item.size, hash);
|
||||||
if (!xml) {
|
if (xml === null) {
|
||||||
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||||||
}
|
}
|
||||||
await danmakuCacheDao.upsert({
|
await danmakuCacheDao.upsert({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue