import { createHash } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao"; import { HASH_HEAD_BYTES, hasOpenCredentials, headMd5, matchFileName, openCommentXml, openMatch, rankCandidates, } from "@app/danmaku"; import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types"; import { decryptSecret } from "./secret.js"; import { createWebdav, joinWebdavPath } from "./webdav-client.js"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; function matchKeyFor(path: string, size: number): string { return createHash("sha256").update(`${path}:${size}`).digest("hex"); } /** 取 WebDAV 文件前 16MB 的 MD5(开放网络文件识别用) */ export 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 = joinWebdavPath(mount.rootPath || "/", 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 persistOpenXml(userId: string, mediaItemId: string, xml: string): Promise { await danmakuDocsDao.upsert({ userId, mediaItemId, source: "open-network", xml, byteSize: Buffer.byteLength(xml, "utf8"), }); } async function persistOpenCache(episodeId: number, xml: string): Promise { await danmakuCacheDao.upsert({ matchKey: String(episodeId), payload: xml, source: "open-network", expiresAt: new Date(Date.now() + CACHE_TTL_MS), }); } 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: "媒体不存在" }; const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml"); if (local) return { ok: true, source: "local-xml", xml: local.xml }; if (!hasOpenCredentials()) { return { ok: true, source: "none", xml: "", message: "未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET", }; } const cacheKey = item.dandanplayEpisodeId != null ? String(item.dandanplayEpisodeId) : matchKeyFor(item.path, item.size); const cached = await danmakuCacheDao.getValid(cacheKey); if (cached) return { ok: true, source: "cache", xml: cached.payload }; let episodeId = item.dandanplayEpisodeId ?? null; if (episodeId == null) { const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); const ranked = rankCandidates(outcome.candidates); if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { return { ok: true, source: "none", xml: "", message: outcome.errorMessage ?? "开放网络未匹配到弹幕", }; } // 仅高置信才自动绑;否则等手选(fetch 不落库) if (!ranked.highConfidence) { return { ok: true, source: "none", xml: "", message: "匹配不明确,请在播放页手选弹幕集", }; } episodeId = ranked.candidates[0]?.episodeId ?? null; if (episodeId == null) { return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; } await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { episodeId, matchedHash: hash, source: "auto", }); } const xml = await openCommentXml(episodeId); if (xml === null) { return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; } await persistOpenXml(userId, mediaItemId, xml); await persistOpenCache(episodeId, xml); return { ok: true, source: "open-network", xml }; }, async matchCandidates(userId: string, mediaItemId: string): Promise { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) { return { ok: false, matched: false, message: "媒体不存在", candidates: [], highConfidence: false, }; } if (!hasOpenCredentials()) { return { ok: false, matched: false, message: "未配置开放弹幕网络", candidates: [], highConfidence: false, }; } const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); const ranked = rankCandidates(outcome.candidates); return { ok: outcome.ok, matched: outcome.isMatched && ranked.candidates.length > 0, message: outcome.errorMessage ?? undefined, candidates: ranked.candidates, highConfidence: ranked.highConfidence, }; }, async selectMatch( userId: string, mediaItemId: string, episodeId: number, ): Promise { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { episodeId, matchedHash: item.matchedHash, source: "manual", }); const xml = await openCommentXml(episodeId); if (xml === null) { return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; } await persistOpenXml(userId, mediaItemId, xml); await persistOpenCache(episodeId, xml); return { ok: true, source: "open-network", xml }; }, async importXml( userId: string, mediaItemId: string, xml: string, ): Promise<{ ok: true; count: number }> { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); if (xml.length > 20_000_000) { throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" }); } if (!xml.includes(" { const row = await danmakuPrefsDao.getByUser(userId); return { enabled: row?.enabled ?? true, opacity: (row?.opacity ?? 80) / 100, density: (row?.density ?? 100) / 100, blockKeywords: row ? (JSON.parse(row.blockKeywords) as string[]) : [], blockTypes: row ? (JSON.parse(row.blockTypes) as Array<"scroll" | "top" | "bottom">) : [], openNetworkConfigured: hasOpenCredentials(), }; }, async savePrefs( userId: string, input: { enabled: boolean; opacity: number; density: number; blockKeywords: string[]; blockTypes: Array<"scroll" | "top" | "bottom">; }, ): Promise { await danmakuPrefsDao.upsert({ userId, enabled: input.enabled, opacity: Math.round(input.opacity * 100), density: Math.round(input.density * 100), blockKeywords: JSON.stringify(input.blockKeywords), blockTypes: JSON.stringify(input.blockTypes), }); return danmakuService.getPrefs(userId); }, };