From 188cf792709d1de164ac7ab5a241f0b3119b7cc6 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 20:22:47 +0800 Subject: [PATCH] feat(trpc): danmaku service uses @app/danmaku and persists docs/prefs --- packages/trpc/package.json | 1 + packages/trpc/src/router/media.router.ts | 29 +- .../trpc/src/services/danmaku.service.test.ts | 392 +++++++++++++---- packages/trpc/src/services/danmaku.service.ts | 402 +++++++----------- packages/trpc/src/services/scrape.service.ts | 46 +- packages/trpc/tsconfig.json | 1 + packages/trpc/vite.config.ts | 1 + yarn.lock | 3 +- 8 files changed, 532 insertions(+), 343 deletions(-) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 8c2b16f..e2664bf 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -23,6 +23,7 @@ "dev": "tsx watch src/server.ts" }, "dependencies": { + "@app/danmaku": "workspace:*", "@app/dao": "workspace:*", "@app/db": "workspace:*", "@app/models": "workspace:*", diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index cf5864a..8659a40 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -6,7 +6,7 @@ import { scrapeSchemas, } from "@app/types"; import { t } from "../context.js"; -import { danmakuService, hasOpenCredentials } from "../services/danmaku.service.js"; +import { danmakuService } from "../services/danmaku.service.js"; import { libraryService } from "../services/library.service.js"; import { mountService } from "../services/mount.service.js"; import { playbackService } from "../services/playback.service.js"; @@ -98,18 +98,25 @@ export const mediaRouter = t.router({ danmakuFetch: protectedProcedure .input(danmakuSchemas.fetch) .query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)), - danmakuImportMeta: protectedProcedure - .input(danmakuSchemas.importMeta) + danmakuMatch: protectedProcedure + .input(danmakuSchemas.match) .mutation(({ ctx, input }) => - danmakuService.importMeta(ctx.userId, input.mediaItemId, input.byteSize), + danmakuService.matchCandidates(ctx.userId, input.mediaItemId), ), - danmakuGetSettings: protectedProcedure.input(danmakuSchemas.getSettings).query(() => ({ - enabled: true, - opacity: 0.8, - density: 1, - openNetworkConfigured: hasOpenCredentials(), - })), + danmakuSelectMatch: protectedProcedure + .input(danmakuSchemas.selectMatch) + .mutation(({ ctx, input }) => + danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId), + ), + danmakuImport: protectedProcedure + .input(danmakuSchemas.import) + .mutation(({ ctx, input }) => + danmakuService.importXml(ctx.userId, input.mediaItemId, input.xml), + ), + danmakuGetSettings: protectedProcedure + .input(danmakuSchemas.getSettings) + .query(({ ctx }) => danmakuService.getPrefs(ctx.userId)), danmakuSaveSettings: protectedProcedure .input(danmakuSchemas.settings) - .mutation(({ input }) => input), + .mutation(({ ctx, input }) => danmakuService.savePrefs(ctx.userId, input)), }); diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 9b68fad..9ee507c 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -1,39 +1,334 @@ -import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { - generateOpenSignature, - headMd5, - matchFileName, - openCommentsToXml, - parseMatchOutcome, -} from "./danmaku.service.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku"; +import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao } from "@app/dao"; +import type { + DanmakuCacheRow, + DanmakuDocRow, + DanmakuPrefsRow, + MediaItem as MediaItemRow, +} from "@app/models"; +import { danmakuService } 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")); +vi.mock("@app/dao", () => ({ + danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() }, + danmakuDocsDao: { get: vi.fn(), upsert: vi.fn() }, + danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() }, + mediaItemDao: { + getByIdForUser: vi.fn(), + setDanmakuMatch: vi.fn(), + }, + mountDao: { getByIdForUser: vi.fn() }, +})); + +vi.mock("@app/danmaku", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openMatch: vi.fn(), + openCommentXml: vi.fn(), + hasOpenCredentials: vi.fn(), + }; +}); + +function mediaItem(over: Partial = {}): MediaItemRow { + return { + id: "m1", + userId: "u1", + mountId: null, + path: "/anime/[Sub] Title - 01.mkv", + rawName: "[Sub] Title - 01.mkv", + title: "Title", + size: 1024, + mime: null, + bangumiId: null, + epNumber: 1, + scrapeStatus: "pending", + scrapedAt: null, + posterUrl: null, + dandanplayEpisodeId: null, + matchedHash: null, + danmakuMatchSource: "none", + scannedAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +function docRow(over: Partial = {}): DanmakuDocRow { + return { + id: "d1", + userId: "u1", + mediaItemId: "m1", + source: "local-xml", + xml: "local", + byteSize: 12, + createdAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +function cacheRow(over: Partial = {}): DanmakuCacheRow { + return { + id: "c1", + matchKey: "4242", + payload: "cached", + source: "open-network", + expiresAt: null, + createdAt: new Date(), + ...over, + }; +} + +function prefsRow(over: Partial = {}): DanmakuPrefsRow { + return { + id: "p1", + userId: "u1", + enabled: true, + opacity: 80, + density: 100, + blockKeywords: "[]", + blockTypes: "[]", + updatedAt: new Date(), + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("danmakuService.importXml", () => { + it("校验媒体存在后落 danmakuDocs(source=local-xml),count 粗算 { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + const xml = + 'ab'; + const r = await danmakuService.importXml("u1", "m1", xml); + expect(r).toEqual({ ok: true, count: 2 }); + expect(danmakuDocsDao.upsert).toHaveBeenCalledWith({ + userId: "u1", + mediaItemId: "m1", + source: "local-xml", + xml, + byteSize: Buffer.byteLength(xml, "utf8"), + }); }); - 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("媒体不存在抛 NOT_FOUND", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null); + await expect(danmakuService.importXml("u1", "gone", "")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect(danmakuDocsDao.upsert).not.toHaveBeenCalled(); }); - it("comments p/m maps to bilibili xml", () => { + it("无 { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + await expect(danmakuService.importXml("u1", "m1", "")).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + }); +}); + +describe("danmakuService.fetch", () => { + it("优先 local-xml:返回该文档且不再调开放网络", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "local" })); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" }); + expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml"); + expect(openMatch).not.toHaveBeenCalled(); + expect(openCommentXml).not.toHaveBeenCalled(); + }); + + it("媒体不存在返回 ok:false", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null); + const r = await danmakuService.fetch("u1", "gone"); + expect(r.ok).toBe(false); + expect(r.source).toBe("none"); + }); + + it("已绑 episodeId 且缓存有效 → source:cache", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ dandanplayEpisodeId: 77 }), + ); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue( + cacheRow({ payload: "cached" }), + ); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "cache", xml: "cached" }); + expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77"); + expect(openCommentXml).not.toHaveBeenCalled(); + }); + + it("未配置开放凭据时明示缺配置", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(false); + const r = await danmakuService.fetch("u1", "m1"); + expect(r.source).toBe("none"); + expect(r.message).toContain("OPEN_DANMAKU_APP_ID"); + expect(openMatch).not.toHaveBeenCalled(); + }); + + it("高置信自动绑(source=auto)后拉取,缓存键为 String(episodeId)", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { + episodeId: 555, + animeId: 1, + animeTitle: "T", + episodeTitle: "E", + imageUrl: null, + }, + ], + }); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { + episodeId: 555, + matchedHash: null, + source: "auto", + }); + expect(openCommentXml).toHaveBeenCalledWith(555); + expect(danmakuDocsDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ source: "open-network", xml: "open" }), + ); + expect(danmakuCacheDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + matchKey: "555", + payload: "open", + source: "open-network", + }), + ); + }); +}); + +describe("danmakuService.selectMatch", () => { + it("写 manual 源绑定后拉取,缓存键 String(episodeId)", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" })); + vi.mocked(openCommentXml).mockResolvedValue("open"); + const r = await danmakuService.selectMatch("u1", "m1", 4242); + expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { + episodeId: 4242, + matchedHash: "abc", + source: "manual", + }); + expect(danmakuCacheDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ matchKey: "4242", payload: "open" }), + ); + expect(danmakuDocsDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ source: "open-network", xml: "open" }), + ); + }); + + it("拉取失败仍保留 manual 绑定,返回 none", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(openCommentXml).mockResolvedValue(null); + const r = await danmakuService.selectMatch("u1", "m1", 4242); + expect(r.source).toBe("none"); + expect(r.message).toContain("拉取失败"); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled(); + expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); + }); +}); + +describe("danmakuService.matchCandidates", () => { + it("透传开放网络候选与 highConfidence", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { episodeId: 9, animeId: 2, animeTitle: "A", episodeTitle: "E1", imageUrl: null }, + ], + }); + const r = await danmakuService.matchCandidates("u1", "m1"); + expect(r.ok).toBe(true); + expect(r.matched).toBe(true); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(9); + }); + + it("媒体不存在 → matched:false", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null); + const r = await danmakuService.matchCandidates("u1", "gone"); + expect(r).toMatchObject({ + ok: false, + matched: false, + candidates: [], + highConfidence: false, + }); + }); +}); + +describe("danmakuService prefs", () => { + it("getPrefs 默认值 + 整数百分比转 0–1 小数", async () => { + vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + const r = await danmakuService.getPrefs("u1"); + expect(r).toEqual({ + enabled: true, + opacity: 0.8, + density: 1, + blockKeywords: [], + blockTypes: [], + openNetworkConfigured: true, + }); + }); + + it("savePrefs 0–1 小数写回整数百分比", async () => { + vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue( + prefsRow({ opacity: 75, density: 50, blockKeywords: '["x"]', blockTypes: '["top"]' }), + ); + vi.mocked(hasOpenCredentials).mockReturnValue(false); + const r = await danmakuService.savePrefs("u1", { + enabled: false, + opacity: 0.75, + density: 0.5, + blockKeywords: ["x"], + blockTypes: ["top"], + }); + expect(danmakuPrefsDao.upsert).toHaveBeenCalledWith({ + userId: "u1", + enabled: false, + opacity: 75, + density: 50, + blockKeywords: '["x"]', + blockTypes: '["top"]', + }); + expect(r.opacity).toBe(0.75); + expect(r.density).toBe(0.5); + expect(r.blockTypes).toEqual(["top"]); + }); +}); + +describe("openCommentsToXml(@app/danmaku,p 序列化 8 段)", () => { + it("comments p/m maps to bilibili xml(8 段 p)", () => { 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>'); + 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'); + expect(xml).toContain('x'); }); it("skips invalid comments", () => { @@ -45,54 +340,3 @@ describe("danmaku open network helpers", () => { ).toBe(''); }); }); - -describe("matchFileName", () => { - it("去文件夹路径与扩展名(官方 match 要求)", () => { - expect(matchFileName("/anime/[Sub] Title - 01 [1080p].mkv")).toBe( - "[Sub] Title - 01 [1080p]", - ); - }); - it("点开头的隐藏文件不算扩展名", () => { - expect(matchFileName(".hidden")).toBe(".hidden"); - }); - it("无扩展名原样保留", () => { - expect(matchFileName("Movie")).toBe("Movie"); - }); -}); - -describe("parseMatchOutcome", () => { - it("提取候选及其刮削元数据", () => { - const r = parseMatchOutcome({ - isMatched: true, - matches: [ - { - episodeId: 111, - animeId: 9, - animeTitle: "葬送的芙莉莲", - episodeTitle: "第1话 冒险终结之地", - imageUrl: "http://img/1.jpg", - }, - ], - }); - expect(r.isMatched).toBe(true); - expect(r.candidates[0]?.episodeId).toBe(111); - expect(r.candidates[0]?.animeId).toBe(9); - expect(r.candidates[0]?.animeTitle).toBe("葬送的芙莉莲"); - expect(r.candidates[0]?.imageUrl).toBe("http://img/1.jpg"); - }); - it("episodeId 缺失的条目丢弃,字符串 id 可解析", () => { - const r = parseMatchOutcome({ - isMatched: false, - matches: [{ episodeId: "222" }, { animeTitle: "no id" }], - }); - expect(r.isMatched).toBe(false); - expect(r.candidates).toHaveLength(1); - expect(r.candidates[0]?.episodeId).toBe(222); - expect(r.candidates[0]?.animeTitle).toBeNull(); - }); - it("空响应安全兜底", () => { - const r = parseMatchOutcome({}); - expect(r.isMatched).toBe(false); - expect(r.candidates).toEqual([]); - }); -}); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 322fac4..d621a3a 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -1,228 +1,20 @@ import { createHash } from "node:crypto"; import { TRPCError } from "@trpc/server"; -import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao"; -import type { DanmakuFetchOutput } from "@app/types"; +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; -/** 官方文件识别 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; -}; - -/** MatchResultV2 刮削子集(swagger:episodeId/animeId/animeTitle/episodeTitle/imageUrl) */ -export type OpenMatchCandidate = { - episodeId: number; - animeId: number | null; - animeTitle: string | null; - episodeTitle: string | null; - imageUrl: string | null; -}; - -export type OpenMatchOutcome = { - ok: boolean; - errorMessage: string | null; - isMatched: boolean; - candidates: OpenMatchCandidate[]; -}; - -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 }; -} - -/** 凭据是否已配置;未配置时开放网络整体不可用,UI 需明示而非静默降级 */ -export function hasOpenCredentials(): boolean { - return openCredentials() !== null; -} - -/** 官方签名: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"); -} - -/** 官方 match 要求 fileName 不含文件夹路径与扩展名;点开头的隐藏文件不算扩展名 */ -export function matchFileName(path: string): string { - const base = path.split("/").pop() ?? path; - const dot = base.lastIndexOf("."); - return dot > 0 ? base.slice(0, dot) : base; -} - -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 headers: Record = { "Content-Type": "application/json" }; - const creds = openCredentials(); - if (!creds) return headers; - const timestamp = Math.floor(Date.now() / 1000); - return { - ...headers, - "X-AppId": creds.appId, - "X-Timestamp": String(timestamp), - "X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret), - }; -} - -type OpenApiBase = { - errorCode?: number; - success?: boolean; - errorMessage?: string | null; - errorDetail?: string | null; -}; - -/** 开放网络统一请求:拼 base + 鉴权头;HTTP 非 2xx / success=false / errorCode≠0 / 网络错误统一归到 ok:false */ -export async function openFetchJson( - path: string, - init?: { method?: "POST"; body?: string } | undefined, -): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { - try { - const res = await fetch(`${openApiBase()}${path}`, { - method: init?.method ?? "GET", - headers: openHeaders(path), - ...(init?.body !== undefined ? { body: init.body } : {}), - }); - if (!res.ok) { - return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` }; - } - const json: unknown = await res.json(); - const base = json as OpenApiBase; - if (base.success === false || (base.errorCode ?? 0) !== 0) { - return { - ok: false, - status: res.status, - error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败", - }; - } - return { ok: true, json }; - } catch (e) { - return { - ok: false, - status: null, - error: e instanceof Error ? e.message : "弹弹play API 网络错误", - }; - } -} - -type RawMatch = { - episodeId?: number | string; - animeId?: number | string; - animeTitle?: string | null; - episodeTitle?: string | null; - imageUrl?: string | null; -}; - -/** 解析 /api/v2/match 响应体为候选列表(纯函数便于单测);无 episodeId 的条目丢弃 */ -export function parseMatchOutcome(json: unknown): { - isMatched: boolean; - candidates: OpenMatchCandidate[]; -} { - const body = json as { isMatched?: boolean; matches?: RawMatch[] }; - const candidates: OpenMatchCandidate[] = []; - for (const m of body.matches ?? []) { - const episodeId = - typeof m.episodeId === "number" - ? m.episodeId - : Number.parseInt(String(m.episodeId ?? ""), 10); - if (!Number.isFinite(episodeId)) continue; - const animeId = - typeof m.animeId === "number" - ? m.animeId - : Number.parseInt(String(m.animeId ?? ""), 10); - candidates.push({ - episodeId, - animeId: Number.isFinite(animeId) ? animeId : null, - animeTitle: m.animeTitle ?? null, - episodeTitle: m.episodeTitle ?? null, - imageUrl: m.imageUrl ?? null, - }); - } - return { isMatched: body.isMatched === true, candidates }; -} - -/** POST /api/v2/match;错误透出 errorMessage,不再吞成 null */ -export async function openMatch( - fileName: string, - fileSize: number, - fileHash: string | null, -): Promise { - const res = await openFetchJson("/api/v2/match", { - method: "POST", - body: JSON.stringify({ - fileName, - fileHash: fileHash ?? "", - fileSize, - matchMode: fileHash ? "hashAndFileName" : "fileNameOnly", - }), - }); - if (!res.ok) { - return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] }; - } - const parsed = parseMatchOutcome(res.json); - return { ok: true, errorMessage: null, ...parsed }; -} - -/** GET /api/v2/comment/{episodeId}?withRelated=true → Bilibili 式 XML */ -export async function openCommentXml(episodeId: number): Promise { - const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`); - if (!res.ok) return null; - const body = res.json as { comments?: OpenComment[] }; - return openCommentsToXml(body.comments ?? []); -} function matchKeyFor(path: string, size: number): string { return createHash("sha256").update(`${path}:${size}`).digest("hex"); @@ -258,12 +50,34 @@ export async function remoteFileHash(userId: string, mediaId: 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: "媒体不存在" }; - if (!openCredentials()) { + 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", @@ -273,18 +87,19 @@ export const danmakuService = { }; } - const matchKey = matchKeyFor(item.path, item.size); - const cached = await danmakuCacheDao.getValid(matchKey); - if (cached) { - return { ok: true, source: "cache", xml: cached.payload }; - } + 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 best = outcome.candidates[0]; - if (!outcome.ok || !outcome.isMatched || !best) { + const ranked = rankCandidates(outcome.candidates); + if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { return { ok: true, source: "none", @@ -292,11 +107,23 @@ export const danmakuService = { message: outcome.errorMessage ?? "开放网络未匹配到弹幕", }; } - episodeId = best.episodeId; - // 识别身份落库:后续播放直接 comment,免重跑 16MB 读流 + match(元数据覆盖走库页识别入口) + // 仅高置信才自动绑;否则等手选(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", }); } @@ -304,22 +131,119 @@ export const danmakuService = { 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), - }); + await persistOpenXml(userId, mediaItemId, xml); + await persistOpenCache(episodeId, xml); return { ok: true, source: "open-network", xml }; }, - /** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */ - async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> { + 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 (byteSize > 20_000_000) { + if (xml.length > 20_000_000) { throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" }); } - return { ok: true }; + 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); }, }; diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index 005b04c..df45efa 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -1,13 +1,14 @@ import { TRPCError } from "@trpc/server"; import { mediaItemDao } from "@app/dao"; -import type { BatchResult, DanmakuMatchOutput } from "@app/types"; import { hasOpenCredentials, matchFileName, openFetchJson, openMatch, - remoteFileHash, -} from "./danmaku.service.js"; + rankCandidates, +} from "@app/danmaku"; +import type { BatchResult, DanmakuMatchOutput } from "@app/types"; +import { remoteFileHash } from "./danmaku.service.js"; /** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */ export function parseEpisodeFromFilename(name: string): number | null { @@ -216,6 +217,8 @@ export const scrapeService = { matched: false, message: "未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET", + candidates: [], + highConfidence: false, }; } @@ -226,8 +229,11 @@ export const scrapeService = { ok: false, matched: false, message: outcome.errorMessage ?? "弹弹play 识别失败", + candidates: [], + highConfidence: false, }; } + const ranked = rankCandidates(outcome.candidates); const best = outcome.candidates[0]; if (best) { await mediaItemDao.updateScrape(mediaItemId, userId, { @@ -242,13 +248,8 @@ export const scrapeService = { return { ok: true, matched: true, - result: { - episodeId: best.episodeId, - animeId: best.animeId, - animeTitle: best.animeTitle, - episodeTitle: best.episodeTitle, - imageUrl: best.imageUrl, - }, + candidates: ranked.candidates, + highConfidence: ranked.highConfidence, }; } @@ -268,17 +269,26 @@ export const scrapeService = { ok: true, matched: true, message: "已通过 Bangumi 绑定桥接到弹弹play 剧集", - result: { - episodeId: bridged.episodeId, - animeId: null, - animeTitle: row.title, - episodeTitle: bridged.episodeTitle, - imageUrl: row.posterUrl, - }, + candidates: [ + { + episodeId: bridged.episodeId, + animeId: null, + animeTitle: row.title, + episodeTitle: bridged.episodeTitle, + imageUrl: row.posterUrl, + }, + ], + highConfidence: true, }; } } - return { ok: true, matched: false, message: "弹弹play 未识别到该文件" }; + return { + ok: true, + matched: false, + message: "弹弹play 未识别到该文件", + candidates: [], + highConfidence: false, + }; }, /** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */ diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json index b00e666..2fd432f 100644 --- a/packages/trpc/tsconfig.json +++ b/packages/trpc/tsconfig.json @@ -5,6 +5,7 @@ "composite": false, "types": ["node"], "paths": { + "@app/danmaku": ["../danmaku/src/index.ts"], "@app/types": ["../types/src/index.ts"], "@app/dao": ["../dao/src/index.ts"], "@app/db": ["../db/src/index.ts"], diff --git a/packages/trpc/vite.config.ts b/packages/trpc/vite.config.ts index 283d5fa..e6db1c1 100644 --- a/packages/trpc/vite.config.ts +++ b/packages/trpc/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ }, rollupOptions: { external: [ + "@app/danmaku", "@app/dao", "@app/db", "@app/models", diff --git a/yarn.lock b/yarn.lock index 75cfa7c..17ad971 100644 --- a/yarn.lock +++ b/yarn.lock @@ -221,7 +221,7 @@ __metadata: languageName: unknown linkType: soft -"@app/danmaku@workspace:packages/danmaku": +"@app/danmaku@workspace:*, @app/danmaku@workspace:packages/danmaku": version: 0.0.0-use.local resolution: "@app/danmaku@workspace:packages/danmaku" dependencies: @@ -314,6 +314,7 @@ __metadata: version: 0.0.0-use.local resolution: "@app/trpc@workspace:packages/trpc" dependencies: + "@app/danmaku": "workspace:*" "@app/dao": "workspace:*" "@app/db": "workspace:*" "@app/models": "workspace:*"