app-template/packages/trpc/src/services/danmaku.service.ts

203 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 开放网络 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 };
},
};