From 517b4b1ef6a0b6f48c79c981083c8692c406d36c Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 18:54:55 +0800 Subject: [PATCH 01/17] feat(danmaku): protocol package scaffold with signature, hash, xml --- packages/danmaku/package.json | 25 ++++++ packages/danmaku/src/hash.test.ts | 17 ++++ packages/danmaku/src/hash.ts | 15 ++++ packages/danmaku/src/index.ts | 4 + packages/danmaku/src/signature.test.ts | 14 ++++ packages/danmaku/src/signature.ts | 11 +++ packages/danmaku/src/types.ts | 19 +++++ packages/danmaku/src/xml.test.ts | 15 ++++ packages/danmaku/src/xml.ts | 112 +++++++++++++++++++++++++ packages/danmaku/tsconfig.build.json | 11 +++ packages/danmaku/tsconfig.json | 9 ++ yarn.lock | 10 +++ 12 files changed, 262 insertions(+) create mode 100644 packages/danmaku/package.json create mode 100644 packages/danmaku/src/hash.test.ts create mode 100644 packages/danmaku/src/hash.ts create mode 100644 packages/danmaku/src/index.ts create mode 100644 packages/danmaku/src/signature.test.ts create mode 100644 packages/danmaku/src/signature.ts create mode 100644 packages/danmaku/src/types.ts create mode 100644 packages/danmaku/src/xml.test.ts create mode 100644 packages/danmaku/src/xml.ts create mode 100644 packages/danmaku/tsconfig.build.json create mode 100644 packages/danmaku/tsconfig.json diff --git a/packages/danmaku/package.json b/packages/danmaku/package.json new file mode 100644 index 0000000..4cb1b01 --- /dev/null +++ b/packages/danmaku/package.json @@ -0,0 +1,25 @@ +{ + "name": "@app/danmaku", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.15.29", + "typescript": "^7.0.2", + "vitest": "^5.0.1" + } +} diff --git a/packages/danmaku/src/hash.test.ts b/packages/danmaku/src/hash.test.ts new file mode 100644 index 0000000..f0a171a --- /dev/null +++ b/packages/danmaku/src/hash.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { headMd5, matchFileName } from "./hash.js"; + +describe("headMd5", () => { + it("md5 of sample", () => { + expect(headMd5(Buffer.from("abc"))).toBe("900150983cd24fb0d6963f7d28e17f72"); + }); +}); + +describe("matchFileName", () => { + it("strips folder and extension", () => { + expect(matchFileName("a/b/[Sub] Show - 01.mkv")).toBe("[Sub] Show - 01"); + }); + it("keeps dotfiles without extension strip", () => { + expect(matchFileName("a/.hidden")).toBe(".hidden"); + }); +}); diff --git a/packages/danmaku/src/hash.ts b/packages/danmaku/src/hash.ts new file mode 100644 index 0000000..fbcd0b5 --- /dev/null +++ b/packages/danmaku/src/hash.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; + +export const HASH_HEAD_BYTES = 16 * 1024 * 1024; + +/** 文件头部 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; +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts new file mode 100644 index 0000000..62a6502 --- /dev/null +++ b/packages/danmaku/src/index.ts @@ -0,0 +1,4 @@ +export { generateOpenSignature } from "./signature.js"; +export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js"; +export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js"; +export type { DanmakuComment, MatchCandidate } from "./types.js"; diff --git a/packages/danmaku/src/signature.test.ts b/packages/danmaku/src/signature.test.ts new file mode 100644 index 0000000..47501b3 --- /dev/null +++ b/packages/danmaku/src/signature.test.ts @@ -0,0 +1,14 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { generateOpenSignature } from "./signature.js"; + +describe("generateOpenSignature", () => { + it("base64(sha256(AppId + Timestamp + Path + AppSecret))", () => { + // 固定向量:与现 danmaku.service 算法一致 + const sig = generateOpenSignature("app1", 1700000000, "/api/v2/match", "sec"); + const expectB64 = createHash("sha256") + .update(`app1${1700000000}/api/v2/matchsec`) + .digest("base64"); + expect(sig).toBe(expectB64); + }); +}); diff --git a/packages/danmaku/src/signature.ts b/packages/danmaku/src/signature.ts new file mode 100644 index 0000000..209eda8 --- /dev/null +++ b/packages/danmaku/src/signature.ts @@ -0,0 +1,11 @@ +import { createHash } from "node:crypto"; + +/** 官方签名: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"); +} diff --git a/packages/danmaku/src/types.ts b/packages/danmaku/src/types.ts new file mode 100644 index 0000000..b058d1c --- /dev/null +++ b/packages/danmaku/src/types.ts @@ -0,0 +1,19 @@ +export type DanmakuComment = { + time: number; + mode: number; + size: number; + color: number; + timestamp: number; + pool: number; + uid: string; + rowId: string; + text: string; +}; + +export type MatchCandidate = { + episodeId: number; + animeId: number | null; + animeTitle: string | null; + episodeTitle: string | null; + imageUrl: string | null; +}; diff --git a/packages/danmaku/src/xml.test.ts b/packages/danmaku/src/xml.test.ts new file mode 100644 index 0000000..008e6e1 --- /dev/null +++ b/packages/danmaku/src/xml.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js"; + +describe("xml round-trip", () => { + it("parse then serialize preserves time/mode/text", () => { + const xml = openCommentsToXml([{ p: "1.5,1,25,16777215,0,0,u,1", m: "你好" }]); + const list = parseDanmakuXml(xml); + expect(list).toHaveLength(1); + expect(list[0]?.time).toBeCloseTo(1.5); + expect(list[0]?.mode).toBe(1); + expect(list[0]?.text).toBe("你好"); + const again = parseDanmakuXml(serializeDanmakuXml(list)); + expect(again[0]?.text).toBe("你好"); + }); +}); diff --git a/packages/danmaku/src/xml.ts b/packages/danmaku/src/xml.ts new file mode 100644 index 0000000..0560e5b --- /dev/null +++ b/packages/danmaku/src/xml.ts @@ -0,0 +1,112 @@ +import type { DanmakuComment } from "./types.js"; + +export type OpenComment = { + p?: string | undefined; + m?: string | undefined; + text?: string | undefined; + mode?: number | undefined; + time?: number | undefined; +}; + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function unescapeXml(s: string): string { + return s + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +function numOr(v: string | undefined, fallback: number): number { + if (v === undefined || v === "") return fallback; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +/** `p` = time,mode,size,color,timestamp,pool,uid,rowid;缺省 size/color/timestamp/pool=0,mode 非法时回退 1 */ +function parseP(p: string): DanmakuComment { + const seg = unescapeXml(p).split(","); + return { + time: numOr(seg[0], Number.NaN), + mode: numOr(seg[1], 1) || 1, + size: numOr(seg[2], 0), + color: numOr(seg[3], 0), + timestamp: numOr(seg[4], 0), + pool: numOr(seg[5], 0), + uid: seg[6] ?? "", + rowId: seg[7] ?? "", + text: "", + }; +} + +function toComment(c: OpenComment): DanmakuComment | null { + if (typeof c.p === "string") { + const base = parseP(c.p); + base.text = c.m ?? ""; + if (!Number.isFinite(base.time) || !base.text) return null; + return base; + } + const time = Number(c.time); + if (!Number.isFinite(time)) return null; + const text = c.text ?? c.m ?? ""; + if (!text) return null; + return { + time, + mode: Number(c.mode) || 1, + size: 0, + color: 0, + timestamp: 0, + pool: 0, + uid: "", + rowId: "", + text, + }; +} + +/** 开放网络 comments → Bilibili 式 XML(`text`) */ +export function openCommentsToXml(comments: OpenComment[]): string { + const nodes: string[] = []; + for (const c of comments) { + const comment = toComment(c); + if (comment) nodes.push(commentNode(comment)); + } + return wrapXml(nodes); +} + +function commentNode(c: DanmakuComment): string { + const p = `${c.time},${c.mode},${c.size},${c.color},${c.timestamp},${c.pool},${c.uid},${c.rowId}`; + return `${escapeXml(c.text)}`; +} + +function wrapXml(nodes: string[]): string { + return `${nodes.join("")}`; +} + +export function parseDanmakuXml(xml: string): DanmakuComment[] { + const list: DanmakuComment[] = []; + for (const m of xml.matchAll(/([\s\S]*?)<\/d>/g)) { + const p = m[1]; + const body = m[2]; + if (p === undefined || body === undefined) continue; + const comment = parseP(p); + comment.text = unescapeXml(body); + if (!Number.isFinite(comment.time)) continue; + list.push(comment); + } + return list; +} + +/** 完整 8 段 `p` 序列化,保证与解析互为逆操作 */ +export function serializeDanmakuXml(list: DanmakuComment[]): string { + const nodes: string[] = []; + for (const c of list) nodes.push(commentNode(c)); + return wrapXml(nodes); +} diff --git a/packages/danmaku/tsconfig.build.json b/packages/danmaku/tsconfig.build.json new file mode 100644 index 0000000..f6a099b --- /dev/null +++ b/packages/danmaku/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/danmaku/tsconfig.json b/packages/danmaku/tsconfig.json new file mode 100644 index 0000000..f83471c --- /dev/null +++ b/packages/danmaku/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "paths": {} + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index b9dacdc..75cfa7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -221,6 +221,16 @@ __metadata: languageName: unknown linkType: soft +"@app/danmaku@workspace:packages/danmaku": + version: 0.0.0-use.local + resolution: "@app/danmaku@workspace:packages/danmaku" + dependencies: + "@types/node": "npm:^22.15.29" + typescript: "npm:^7.0.2" + vitest: "npm:^5.0.1" + languageName: unknown + linkType: soft + "@app/dao@workspace:*, @app/dao@workspace:packages/dao": version: 0.0.0-use.local resolution: "@app/dao@workspace:packages/dao" From b80b1e1fbf0b038f05dc305e5a53799e02646a0d Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:05:49 +0800 Subject: [PATCH 02/17] test(danmaku): lock 8-segment p contract and parse tolerance --- packages/danmaku/src/xml.test.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/danmaku/src/xml.test.ts b/packages/danmaku/src/xml.test.ts index 008e6e1..5f78a9d 100644 --- a/packages/danmaku/src/xml.test.ts +++ b/packages/danmaku/src/xml.test.ts @@ -4,12 +4,51 @@ import { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j describe("xml round-trip", () => { it("parse then serialize preserves time/mode/text", () => { const xml = openCommentsToXml([{ p: "1.5,1,25,16777215,0,0,u,1", m: "你好" }]); + expect(xml).toContain('p="1.5,1,25,16777215,0,0,u,1"'); const list = parseDanmakuXml(xml); expect(list).toHaveLength(1); expect(list[0]?.time).toBeCloseTo(1.5); expect(list[0]?.mode).toBe(1); expect(list[0]?.text).toBe("你好"); const again = parseDanmakuXml(serializeDanmakuXml(list)); + expect(again[0]?.time).toBeCloseTo(1.5); + expect(again[0]?.mode).toBe(1); + expect(again[0]?.size).toBe(25); + expect(again[0]?.color).toBe(16777215); + expect(again[0]?.uid).toBe("u"); + expect(again[0]?.rowId).toBe("1"); expect(again[0]?.text).toBe("你好"); }); }); + +describe("parseDanmakuXml tolerance", () => { + it("4-segment p falls back size/color/uid/rowId to defaults", () => { + const list = parseDanmakuXml('x'); + expect(list).toHaveLength(1); + expect(list[0]?.time).toBeCloseTo(12.5); + expect(list[0]?.mode).toBe(1); + expect(list[0]?.size).toBe(0); + expect(list[0]?.color).toBe(0); + expect(list[0]?.timestamp).toBe(0); + expect(list[0]?.pool).toBe(0); + expect(list[0]?.uid).toBe(""); + expect(list[0]?.rowId).toBe(""); + expect(list[0]?.text).toBe("x"); + }); + + it("shorter p defaults size/color to 0 and mode to 1", () => { + const two = parseDanmakuXml('y'); + expect(two[0]?.time).toBeCloseTo(1.5); + expect(two[0]?.mode).toBe(1); + expect(two[0]?.size).toBe(0); + expect(two[0]?.color).toBe(0); + expect(two[0]?.uid).toBe(""); + expect(two[0]?.rowId).toBe(""); + + const one = parseDanmakuXml('z'); + expect(one[0]?.time).toBe(3); + expect(one[0]?.mode).toBe(1); + expect(one[0]?.size).toBe(0); + expect(one[0]?.color).toBe(0); + }); +}); From 967ef58503ba1792b08c811ace7e410b8af662e6 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:15:10 +0800 Subject: [PATCH 03/17] feat(danmaku): match/comment client, filter, high-confidence rank --- packages/danmaku/src/client.test.ts | 52 ++++++++++++++++ packages/danmaku/src/client.ts | 97 +++++++++++++++++++++++++++++ packages/danmaku/src/filter.test.ts | 53 ++++++++++++++++ packages/danmaku/src/filter.ts | 25 ++++++++ packages/danmaku/src/index.ts | 14 +++++ packages/danmaku/src/match.test.ts | 32 ++++++++++ packages/danmaku/src/match.ts | 65 +++++++++++++++++++ 7 files changed, 338 insertions(+) create mode 100644 packages/danmaku/src/client.test.ts create mode 100644 packages/danmaku/src/client.ts create mode 100644 packages/danmaku/src/filter.test.ts create mode 100644 packages/danmaku/src/filter.ts create mode 100644 packages/danmaku/src/match.test.ts create mode 100644 packages/danmaku/src/match.ts diff --git a/packages/danmaku/src/client.test.ts b/packages/danmaku/src/client.test.ts new file mode 100644 index 0000000..ab0df62 --- /dev/null +++ b/packages/danmaku/src/client.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hasOpenCredentials, openApiBase, openHeaders, readOpenCredentials } from "./client.js"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("readOpenCredentials", () => { + it("reads from injected env", () => { + expect( + readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id", OPEN_DANMAKU_APP_SECRET: "s" }), + ).toEqual({ + appId: "id", + appSecret: "s", + }); + }); + it("returns null when missing", () => { + expect(readOpenCredentials({})).toBeNull(); + expect(readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id" })).toBeNull(); + }); + it("defaults to process.env", () => { + vi.stubEnv("OPEN_DANMAKU_APP_ID", "pid"); + vi.stubEnv("OPEN_DANMAKU_APP_SECRET", "ps"); + expect(readOpenCredentials()).toEqual({ appId: "pid", appSecret: "ps" }); + expect(hasOpenCredentials()).toBe(true); + }); +}); + +describe("openApiBase", () => { + it("strips trailing slashes", () => { + vi.stubEnv("OPEN_DANMAKU_API_BASE", "https://example.test///"); + expect(openApiBase()).toBe("https://example.test"); + }); + it("defaults to dandanplay api", () => { + vi.stubEnv("OPEN_DANMAKU_API_BASE", undefined); + expect(openApiBase()).toBe("https://api.dandanplay.net"); + }); +}); + +describe("openHeaders", () => { + it("signs request when credentials present", () => { + const h = openHeaders("/api/v2/match", { appId: "id", appSecret: "s" }); + expect(h["Content-Type"]).toBe("application/json"); + expect(h["X-AppId"]).toBe("id"); + expect(h["X-Timestamp"]).toMatch(/^\d+$/); + expect(h["X-Signature"]).toBeTruthy(); + }); + it("only content-type without credentials", () => { + expect(openHeaders("/api/v2/match", null)).toEqual({ "Content-Type": "application/json" }); + }); +}); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts new file mode 100644 index 0000000..c8cedad --- /dev/null +++ b/packages/danmaku/src/client.ts @@ -0,0 +1,97 @@ +import { parseMatchOutcome, type OpenMatchOutcome } from "./match.js"; +import { generateOpenSignature } from "./signature.js"; +import { openCommentsToXml } from "./xml.js"; + +export type OpenCredentials = { appId: string; appSecret: string }; + +export function openApiBase(): string { + return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace( + /\/+$/, + "", + ); +} + +export function readOpenCredentials(env = process.env): OpenCredentials | null { + const appId = env["OPEN_DANMAKU_APP_ID"]; + const appSecret = env["OPEN_DANMAKU_APP_SECRET"]; + if (!appId || !appSecret) return null; + return { appId, appSecret }; +} + +export function hasOpenCredentials(): boolean { + return readOpenCredentials() !== null; +} + +export function openHeaders(path: string, creds: OpenCredentials | null): Record { + const headers: Record = { "Content-Type": "application/json" }; + 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), + }; +} + +export async function openFetchJson( + path: string, + init?: { method?: "POST"; body?: string } | undefined, +): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { + const creds = readOpenCredentials(); + try { + const res = await fetch(`${openApiBase()}${path}`, { + method: init?.method ?? "GET", + headers: openHeaders(path, creds), + ...(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 { + success?: boolean; + errorCode?: number; + errorMessage?: string | null; + errorDetail?: string | null; + }; + 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 网络错误", + }; + } +} + +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: [] }; + return { ok: true, errorMessage: null, ...parseMatchOutcome(res.json) }; +} + +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?: Array> }; + return openCommentsToXml(body.comments ?? []); +} diff --git a/packages/danmaku/src/filter.test.ts b/packages/danmaku/src/filter.test.ts new file mode 100644 index 0000000..c31d71a --- /dev/null +++ b/packages/danmaku/src/filter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { filterComments, modeToKind } from "./filter.js"; +import type { DanmakuComment } from "./types.js"; + +const c = (text: string, mode: number): DanmakuComment => ({ + time: 0, + mode, + size: 25, + color: 16777215, + timestamp: 0, + pool: 0, + uid: "u", + rowId: "1", + text, +}); + +describe("filterComments", () => { + it("drops keyword hits", () => { + const out = filterComments([c("好番", 1), c("剧透狗", 1)], { + blockKeywords: ["剧透"], + blockTypes: [], + }); + expect(out.map((x) => x.text)).toEqual(["好番"]); + }); + it("drops blocked types (1 scroll, 5 top, 4 bottom)", () => { + const out = filterComments([c("a", 1), c("b", 5), c("c", 4)], { + blockKeywords: [], + blockTypes: ["top"], + }); + expect(out.map((x) => x.mode)).toEqual([1, 4]); + }); +}); + +describe("modeToKind", () => { + it("maps modes per dandanplay convention", () => { + expect(modeToKind(1)).toBe("scroll"); + expect(modeToKind(2)).toBe("scroll"); + expect(modeToKind(3)).toBe("scroll"); + expect(modeToKind(6)).toBe("scroll"); + expect(modeToKind(4)).toBe("bottom"); + expect(modeToKind(5)).toBe("top"); + expect(modeToKind(7)).toBe("other"); + expect(modeToKind(9)).toBe("other"); + }); + + it("keeps other-kind comments even when blockTypes is full", () => { + const out = filterComments([c("code", 9), c("x", 7)], { + blockKeywords: [], + blockTypes: ["scroll", "top", "bottom"], + }); + expect(out.map((x) => x.mode)).toEqual([9, 7]); + }); +}); diff --git a/packages/danmaku/src/filter.ts b/packages/danmaku/src/filter.ts new file mode 100644 index 0000000..e5976cd --- /dev/null +++ b/packages/danmaku/src/filter.ts @@ -0,0 +1,25 @@ +import type { DanmakuComment } from "./types.js"; + +export type FilterOptions = { + blockKeywords: string[]; + blockTypes: Array<"scroll" | "top" | "bottom">; +}; + +/** mode:1/2/3=滚动 4=底部 5=顶部 6=逆向 7=特殊 9=代码 */ +export function modeToKind(mode: number): "scroll" | "top" | "bottom" | "other" { + if (mode === 4) return "bottom"; + if (mode === 5) return "top"; + if (mode === 1 || mode === 2 || mode === 3 || mode === 6) return "scroll"; + return "other"; +} + +export function filterComments(list: DanmakuComment[], opts: FilterOptions): DanmakuComment[] { + const kinds = new Set(opts.blockTypes); + const kws = opts.blockKeywords.map((k) => k.trim()).filter(Boolean); + return list.filter((c) => { + const kind = modeToKind(c.mode); + if (kind !== "other" && kinds.has(kind)) return false; + if (kws.some((k) => c.text.includes(k))) return false; + return true; + }); +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 62a6502..7a438a6 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -1,4 +1,18 @@ export { generateOpenSignature } from "./signature.js"; export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js"; export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js"; +export { filterComments, modeToKind } from "./filter.js"; +export type { FilterOptions } from "./filter.js"; +export { parseMatchOutcome, rankCandidates } from "./match.js"; +export type { OpenMatchOutcome } from "./match.js"; +export { + hasOpenCredentials, + openApiBase, + openCommentXml, + openFetchJson, + openHeaders, + openMatch, + readOpenCredentials, +} from "./client.js"; +export type { OpenCredentials } from "./client.js"; export type { DanmakuComment, MatchCandidate } from "./types.js"; diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts new file mode 100644 index 0000000..13b6b47 --- /dev/null +++ b/packages/danmaku/src/match.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { parseMatchOutcome, rankCandidates } from "./match.js"; + +describe("parseMatchOutcome", () => { + it("maps matches and drops bad episodeId", () => { + const r = parseMatchOutcome({ + isMatched: true, + matches: [{ episodeId: 12, animeTitle: "A", episodeTitle: "01" }, { episodeId: "x" }], + }); + expect(r.isMatched).toBe(true); + expect(r.candidates).toHaveLength(1); + expect(r.candidates[0]?.episodeId).toBe(12); + }); +}); + +describe("rankCandidates", () => { + it("single candidate is high confidence", () => { + const r = rankCandidates([ + { episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "01", imageUrl: null }, + ]); + expect(r.highConfidence).toBe(true); + expect(r.candidates).toHaveLength(1); + }); + it("close scores are not high confidence", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null }, + { episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01v2", imageUrl: null }, + ]; + const r = rankCandidates(list); + expect(r.highConfidence).toBe(false); + }); +}); diff --git a/packages/danmaku/src/match.ts b/packages/danmaku/src/match.ts new file mode 100644 index 0000000..67d4b0f --- /dev/null +++ b/packages/danmaku/src/match.ts @@ -0,0 +1,65 @@ +import type { MatchCandidate } from "./types.js"; + +export type OpenMatchOutcome = { + ok: boolean; + errorMessage: string | null; + isMatched: boolean; + candidates: MatchCandidate[]; +}; + +type RawMatch = { + episodeId?: number | string; + animeId?: number | string; + animeTitle?: string | null; + episodeTitle?: string | null; + imageUrl?: string | null; +}; + +export function parseMatchOutcome(json: unknown): { + isMatched: boolean; + candidates: MatchCandidate[]; +} { + const body = json as { isMatched?: boolean; matches?: RawMatch[] }; + const candidates: MatchCandidate[] = []; + 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 }; +} + +/** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */ +export function rankCandidates( + candidates: MatchCandidate[], + opts?: { lead?: number } | undefined, +): { candidates: MatchCandidate[]; highConfidence: boolean } { + const lead = opts?.lead ?? 0.15; + if (candidates.length === 0) return { candidates: [], highConfidence: false }; + if (candidates.length === 1) return { candidates, highConfidence: true }; + const scored = candidates + .map((c) => ({ + c, + score: + (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0), + })) + .sort((a, b) => b.score - a.score); + const top = scored[0]; + const second = scored[1]; + if (!top) return { candidates, highConfidence: false }; + const high = second ? top.score - second.score >= lead : true; + return { candidates: scored.map((s) => s.c), highConfidence: high }; +} From 5ce1f91a265af8fb4b5595cdfe64b06f64335cda Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:26:44 +0800 Subject: [PATCH 04/17] test(danmaku): cover rankCandidates confidence paths --- packages/danmaku/src/match.test.ts | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts index 13b6b47..ab1c299 100644 --- a/packages/danmaku/src/match.test.ts +++ b/packages/danmaku/src/match.test.ts @@ -29,4 +29,51 @@ describe("rankCandidates", () => { const r = rankCandidates(list); expect(r.highConfidence).toBe(false); }); + it("empty candidates are not high confidence", () => { + const r = rankCandidates([]); + expect(r.highConfidence).toBe(false); + expect(r.candidates).toEqual([]); + }); + it("score gap >= default lead (0.15) is high confidence", () => { + const list = [ + { episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" }, + { episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null }, + ]; + const r = rankCandidates(list); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(1); + }); + it("custom lead is honored", () => { + const list = [ + { episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" }, + { episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null }, + ]; + expect(rankCandidates(list, { lead: 0.5 }).highConfidence).toBe(false); + expect(rankCandidates(list, { lead: 0.3 }).highConfidence).toBe(true); + }); + it("returns candidates sorted by score desc", () => { + const hi = { + episodeId: 1, + animeId: 1, + animeTitle: "A", + episodeTitle: "01", + imageUrl: "img", + }; + const mid = { + episodeId: 2, + animeId: 1, + animeTitle: "B", + episodeTitle: "02", + imageUrl: null, + }; + const low = { + episodeId: 3, + animeId: null, + animeTitle: null, + episodeTitle: null, + imageUrl: "x", + }; + const r = rankCandidates([low, mid, hi]); + expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]); + }); }); From 3467e397e70574232ba131c97fd3a7d46263da06 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:33:37 +0800 Subject: [PATCH 05/17] feat(models,dao): danmaku prefs/docs tables and match source --- packages/dao/src/danmaku-docs.ts | 42 ++++++++++++++++++++++++++++ packages/dao/src/danmaku-prefs.ts | 41 +++++++++++++++++++++++++++ packages/dao/src/index.ts | 2 ++ packages/dao/src/media-items.ts | 7 ++++- packages/models/src/danmaku-docs.ts | 32 +++++++++++++++++++++ packages/models/src/danmaku-prefs.ts | 28 +++++++++++++++++++ packages/models/src/index.ts | 4 +++ packages/models/src/media-items.ts | 2 ++ 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/dao/src/danmaku-docs.ts create mode 100644 packages/dao/src/danmaku-prefs.ts create mode 100644 packages/models/src/danmaku-docs.ts create mode 100644 packages/models/src/danmaku-prefs.ts diff --git a/packages/dao/src/danmaku-docs.ts b/packages/dao/src/danmaku-docs.ts new file mode 100644 index 0000000..be09455 --- /dev/null +++ b/packages/dao/src/danmaku-docs.ts @@ -0,0 +1,42 @@ +import { and, eq } from "drizzle-orm"; +import { danmakuDocs, type DanmakuDocRow } from "@app/models"; +import { db } from "@app/db"; + +export const danmakuDocsDao = { + async get(userId: string, mediaItemId: string, source: string): Promise { + const [row] = await db + .select() + .from(danmakuDocs) + .where( + and( + eq(danmakuDocs.userId, userId), + eq(danmakuDocs.mediaItemId, mediaItemId), + eq(danmakuDocs.source, source), + ), + ) + .limit(1); + return row ?? null; + }, + async upsert(data: { + userId: string; + mediaItemId: string; + source: string; + xml: string; + byteSize: number; + }): Promise { + const existing = await danmakuDocsDao.get(data.userId, data.mediaItemId, data.source); + if (existing) { + const [row] = await db + .update(danmakuDocs) + .set({ xml: data.xml, byteSize: data.byteSize, updatedAt: new Date() }) + .where(eq(danmakuDocs.id, existing.id)) + .returning(); + if (!row) throw new Error("Failed to update danmaku doc"); + return row; + } + const rows = await db.insert(danmakuDocs).values(data).returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert danmaku doc"); + return row; + }, +}; diff --git a/packages/dao/src/danmaku-prefs.ts b/packages/dao/src/danmaku-prefs.ts new file mode 100644 index 0000000..0ff2c03 --- /dev/null +++ b/packages/dao/src/danmaku-prefs.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import { danmakuPrefs, type DanmakuPrefsRow } from "@app/models"; +import { db } from "@app/db"; + +export const danmakuPrefsDao = { + async getByUser(userId: string): Promise { + const [row] = await db + .select() + .from(danmakuPrefs) + .where(eq(danmakuPrefs.userId, userId)) + .limit(1); + return row ?? null; + }, + async upsert(data: { + userId: string; + enabled: boolean; + opacity: number; + density: number; + blockKeywords: string; + blockTypes: string; + }): Promise { + const existing = await db + .select() + .from(danmakuPrefs) + .where(eq(danmakuPrefs.userId, data.userId)) + .limit(1); + if (existing[0]) { + const [row] = await db + .update(danmakuPrefs) + .set({ ...data, updatedAt: new Date() }) + .where(eq(danmakuPrefs.id, existing[0].id)) + .returning(); + if (!row) throw new Error("Failed to update danmaku prefs"); + return row; + } + const rows = await db.insert(danmakuPrefs).values(data).returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert danmaku prefs"); + return row; + }, +}; diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index 8edc9fb..a537aaf 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -11,3 +11,5 @@ export { mediaItemDao } from "./media-items.js"; export type { MediaItemRow } from "./media-items.js"; export { playbackProgressDao } from "./playback-progress.js"; export { danmakuCacheDao } from "./danmaku-cache.js"; +export { danmakuPrefsDao } from "./danmaku-prefs.js"; +export { danmakuDocsDao } from "./danmaku-docs.js"; diff --git a/packages/dao/src/media-items.ts b/packages/dao/src/media-items.ts index a40eee2..578c789 100644 --- a/packages/dao/src/media-items.ts +++ b/packages/dao/src/media-items.ts @@ -280,13 +280,18 @@ export const mediaItemDao = { async setDanmakuMatch( id: string, userId: string, - data: { episodeId: number; matchedHash?: string | null }, + data: { + episodeId: number; + matchedHash?: string | null; + source?: "auto" | "manual" | "none"; + }, ): Promise { const [row] = await db .update(mediaItems) .set({ dandanplayEpisodeId: data.episodeId, matchedHash: data.matchedHash ?? null, + danmakuMatchSource: data.source ?? "auto", updatedAt: new Date(), }) .where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId))) diff --git a/packages/models/src/danmaku-docs.ts b/packages/models/src/danmaku-docs.ts new file mode 100644 index 0000000..0d8b9cd --- /dev/null +++ b/packages/models/src/danmaku-docs.ts @@ -0,0 +1,32 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; +import { mediaItems } from "./media-items.js"; + +export const danmakuDocs = sqliteTable( + "danmaku_docs", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + mediaItemId: text("media_item_id") + .notNull() + .references(() => mediaItems.id, { onDelete: "cascade" }), + /** `open-network` | `local-xml` */ + source: text("source").notNull(), + xml: text("xml").notNull(), + byteSize: integer("byte_size").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("danmaku_docs_user_media_src_uq").on(t.userId, t.mediaItemId, t.source)], +); +export type DanmakuDocRow = typeof danmakuDocs.$inferSelect; +export type NewDanmakuDocRow = typeof danmakuDocs.$inferInsert; diff --git a/packages/models/src/danmaku-prefs.ts b/packages/models/src/danmaku-prefs.ts new file mode 100644 index 0000000..48c70a5 --- /dev/null +++ b/packages/models/src/danmaku-prefs.ts @@ -0,0 +1,28 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; + +export const danmakuPrefs = sqliteTable( + "danmaku_prefs", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + /** 整数百分比 0–100,API 层换算 0–1 小数 */ + opacity: integer("opacity").notNull().default(80), + /** 整数百分比 10–100 */ + density: integer("density").notNull().default(100), + blockKeywords: text("block_keywords").notNull().default("[]"), + blockTypes: text("block_types").notNull().default("[]"), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("danmaku_prefs_user_uq").on(t.userId)], +); +export type DanmakuPrefsRow = typeof danmakuPrefs.$inferSelect; +export type NewDanmakuPrefsRow = typeof danmakuPrefs.$inferInsert; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index c8da20f..91cbf33 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -13,3 +13,7 @@ export { playbackProgress } from "./playback-progress.js"; export type { PlaybackProgress } from "./playback-progress.js"; export { danmakuCache } from "./danmaku-cache.js"; export type { DanmakuCacheRow } from "./danmaku-cache.js"; +export { danmakuPrefs } from "./danmaku-prefs.js"; +export type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js"; +export { danmakuDocs } from "./danmaku-docs.js"; +export type { DanmakuDocRow, NewDanmakuDocRow } from "./danmaku-docs.js"; diff --git a/packages/models/src/media-items.ts b/packages/models/src/media-items.ts index 23f1db6..afd0183 100644 --- a/packages/models/src/media-items.ts +++ b/packages/models/src/media-items.ts @@ -26,6 +26,8 @@ export const mediaItems = sqliteTable( /** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */ dandanplayEpisodeId: integer("dandanplay_episode_id"), matchedHash: text("matched_hash"), + /** auto=高置信自动 | manual=手选 | none */ + danmakuMatchSource: text("danmaku_match_source").notNull().default("none"), scannedAt: integer("scanned_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), From aab636eac7c56bb78db3564a0d5e89b413e40182 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 19:40:43 +0800 Subject: [PATCH 06/17] feat(types): danmaku match candidates, import payload, prefs filters --- packages/types/src/media-schemas.test.ts | 75 +++++++++++++++++++++++- packages/types/src/media-schemas.ts | 31 +++++++--- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/types/src/media-schemas.test.ts b/packages/types/src/media-schemas.test.ts index 8841623..b721ced 100644 --- a/packages/types/src/media-schemas.test.ts +++ b/packages/types/src/media-schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js"; +import { danmakuSchemas, mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js"; describe("mountSchemas.create", () => { it("拒绝缺 baseUrl", () => { @@ -38,3 +38,76 @@ describe("playbackSchemas.report", () => { ).toBe(false); }); }); + +describe("danmakuSchemas.settings", () => { + it("缺省时填充默认值", () => { + const r = danmakuSchemas.settings.safeParse({}); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).toEqual({ + enabled: true, + opacity: 0.8, + density: 1, + blockKeywords: [], + blockTypes: [], + }); + }); + it("blockTypes 只接受 scroll|top|bottom", () => { + expect( + danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] }) + .success, + ).toBe(true); + expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe( + false, + ); + }); + it("opacity 越界拒绝", () => { + expect(danmakuSchemas.settings.safeParse({ opacity: 1.5 }).success).toBe(false); + }); +}); + +describe("danmakuSchemas.import", () => { + const validXml = `hi`; + + it("接受含 { + const r = danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: validXml }); + expect(r.success).toBe(true); + }); + it("拒绝空 xml", () => { + expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe( + false, + ); + }); + it("拒绝不含 { + expect( + danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "no" }) + .success, + ).toBe(false); + }); + it("拒绝超过 20MB 的 xml", () => { + const huge = ` { + expect( + danmakuSchemas.import.safeParse({ + mediaItemId: "m1", + xml: validXml, + fileName: "a".repeat(256), + }).success, + ).toBe(false); + }); +}); + +describe("danmakuSchemas.selectMatch", () => { + it("要求正整数 episodeId", () => { + expect( + danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 42 }).success, + ).toBe(true); + expect( + danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 0 }).success, + ).toBe(false); + }); +}); diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index 9b650f9..e4d8451 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -148,17 +148,29 @@ export const scrapeSchemas = { export const danmakuSchemas = { /** 开放网络:按媒体文件取弹幕 */ fetch: z.object({ mediaItemId: id }), - /** 手动弹弹play 文件识别(match),命中后写刮削元数据 + 弹幕身份 */ + /** 手动识别:返回候选列表,不自动绑 */ match: z.object({ mediaItemId: id }), - /** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */ - importMeta: z.object({ + /** 手选绑定 episodeId */ + selectMatch: z.object({ mediaItemId: id, - byteSize: z.number().min(0).max(20_000_000), + episodeId: z.number().int().positive(), + }), + /** 导入本地 XML 正文(服务端持久化);粗检含弹幕 v.includes("; /** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */ openNetworkConfigured: boolean; }; From 188cf792709d1de164ac7ab5a241f0b3119b7cc6 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 20:22:47 +0800 Subject: [PATCH 07/17] 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:*" From 714f51a1a65e6cea749e55fffac5e99b91441c6c Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 20:39:23 +0800 Subject: [PATCH 08/17] fix(trpc): bind ranked top candidate and cover low-confidence fetch --- .../trpc/src/services/danmaku.service.test.ts | 24 ++++++ .../trpc/src/services/scrape.service.test.ts | 83 ++++++++++++++++++- packages/trpc/src/services/scrape.service.ts | 2 +- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 9ee507c..6292169 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -212,6 +212,30 @@ describe("danmakuService.fetch", () => { }), ); }); + + it("多候选低置信:不自动绑,提示手选", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(danmakuDocsDao.get).mockResolvedValue(null); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null); + // 两条同分(score 1.0),lead 0 < 0.15 → highConfidence false + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { episodeId: 11, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null }, + { episodeId: 22, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null }, + ], + }); + const r = await danmakuService.fetch("u1", "m1"); + expect(r.source).toBe("none"); + expect(r.xml).toBe(""); + expect(r.message).toContain("手选"); + expect(mediaItemDao.setDanmakuMatch).not.toHaveBeenCalled(); + expect(openCommentXml).not.toHaveBeenCalled(); + expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); + }); }); describe("danmakuService.selectMatch", () => { diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts index f379cca..8958dd2 100644 --- a/packages/trpc/src/services/scrape.service.test.ts +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openMatch, hasOpenCredentials } from "@app/danmaku"; +import { mediaItemDao } from "@app/dao"; +import type { MediaItem } from "@app/models"; import { AUTO_BIND_THRESHOLD, buildSearchQuery, @@ -6,6 +9,30 @@ import { pickDandanEpisode, scoreHit, } from "./scrape.service.js"; +import { scrapeService } from "./scrape.service.js"; + +vi.mock("@app/dao", () => ({ + mediaItemDao: { + getByIdForUser: vi.fn(), + updateScrape: vi.fn(), + listByPathPrefix: vi.fn(), + }, +})); + +vi.mock("@app/danmaku", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openMatch: vi.fn(), + openCommentXml: vi.fn(), + hasOpenCredentials: vi.fn(), + openFetchJson: vi.fn(), + }; +}); + +vi.mock("./danmaku.service.js", () => ({ + remoteFileHash: vi.fn().mockResolvedValue("hash"), +})); describe("parseEpisodeFromFilename", () => { it("S01E02", () => { @@ -89,3 +116,57 @@ describe("scoreHit", () => { expect(s).toBeLessThan(AUTO_BIND_THRESHOLD); }); }); + +function mediaItem(over: Partial = {}): MediaItem { + return { + id: "m1", + userId: "u1", + mountId: null, + path: "/anime/Show - 01.mkv", + rawName: "Show - 01.mkv", + title: "Show", + 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, + }; +} + +describe("scrapeService.matchDandanplay 绑定一致性", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(hasOpenCredentials).mockReturnValue(true); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + // score 0:无 animeTitle/episodeTitle → rankCandidates 会排到末尾 + { episodeId: 11, animeId: 1, animeTitle: null, episodeTitle: null, imageUrl: null }, + // score 1.0 → ranked 第一名 + { episodeId: 22, animeId: 2, animeTitle: "T", episodeTitle: "E", imageUrl: null }, + ], + }); + }); + + it("绑定 ranked.candidates[0],且与返回 candidates[0] 一致", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + const r = await scrapeService.matchDandanplay("u1", "m1"); + expect(r.matched).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(22); + expect(mediaItemDao.updateScrape).toHaveBeenCalledWith( + "m1", + "u1", + expect.objectContaining({ dandanplayEpisodeId: 22 }), + ); + }); +}); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index df45efa..a4af10e 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -234,7 +234,7 @@ export const scrapeService = { }; } const ranked = rankCandidates(outcome.candidates); - const best = outcome.candidates[0]; + const best = ranked.candidates[0]; if (best) { await mediaItemDao.updateScrape(mediaItemId, userId, { dandanplayEpisodeId: best.episodeId, From c236f50ec6ffb69574466daebee24b35dc2f290e Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 21:03:01 +0800 Subject: [PATCH 09/17] feat(web): watch rematch candidates, import persist, danmaku filters --- app/web/package.json | 1 + app/web/src/components/pages/WatchBody.tsx | 330 +++++++++++++++++++-- app/web/src/lib/danmaku-render.ts | 49 ++- packages/i18n/src/messages/en.ts | 17 ++ packages/i18n/src/messages/zh-CN.ts | 17 ++ yarn.lock | 1 + 6 files changed, 366 insertions(+), 49 deletions(-) diff --git a/app/web/package.json b/app/web/package.json index de9683e..a92a3e8 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -12,6 +12,7 @@ "lint": "biome check ." }, "dependencies": { + "@app/danmaku": "workspace:*", "@app/design-tokens": "workspace:*", "@app/i18n": "workspace:*", "@app/seo-geo": "workspace:*", diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 7c85e3a..979a4ce 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -1,11 +1,17 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button, toast } from "@app/ui"; +import { Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Artplayer from "artplayer"; import artplayerPluginDanmuku from "artplayer-plugin-danmuku"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render"; +import { + filterComments, + parseDanmakuXml, + thinDanmaku, + toDanmukuItems, + type FilterOptions, +} from "~/lib/danmaku-render"; import { go, queryParam } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { cn } from "~/lib/utils"; @@ -115,14 +121,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const progressStartRef = useRef(0); const prefsRef = useRef(readDanmakuPrefs()); const [localXml, setLocalXml] = useState(""); + /** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */ + const [matchXml, setMatchXml] = useState(""); const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled); const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); const [ended, setEnded] = useState(false); const [playError, setPlayError] = useState(false); + const [matchOpen, setMatchOpen] = useState(false); + const [kwInput, setKwInput] = useState(""); + const [filterDraft, setFilterDraft] = useState(null); // 进入/切换条目时恢复本地导入的弹幕 useEffect(() => { setLocalXml(readLocalXml(mediaItemId)); + setMatchXml(""); + setFilterDraft(null); + setMatchOpen(false); setPlayError(false); setEnded(false); }, [mediaItemId]); @@ -130,8 +144,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn }); + const prefs = trpc.media.danmakuGetSettings.useQuery(); const report = trpc.media.playbackReport.useMutation(); - const importMeta = trpc.media.danmakuImportMeta.useMutation(); + const importXml = trpc.media.danmakuImport.useMutation(); + const matchMut = trpc.media.danmakuMatch.useMutation(); + const selectMut = trpc.media.danmakuSelectMatch.useMutation(); const reportRef = useRef(report); useEffect(() => { reportRef.current = report; @@ -321,11 +338,21 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { } }, [progress.data]); - const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : ""; + const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : ""; const items = useMemo(() => { - const all = xmlSource ? parseDanmakuXml(xmlSource) : []; - return thinDanmaku(all, danmakuDensity); - }, [xmlSource, danmakuDensity]); + const raw = xmlSource ? parseDanmakuXml(xmlSource) : []; + raw.sort((a, b) => a.time - b.time); + const filters: FilterOptions | null = filterDraft + ? filterDraft + : prefs.data + ? { + blockKeywords: prefs.data.blockKeywords, + blockTypes: prefs.data.blockTypes, + } + : null; + const blocked = filters ? filterComments(raw, filters) : raw; + return toDanmukuItems(thinDanmaku(blocked, danmakuDensity)); + }, [xmlSource, prefs.data, filterDraft, danmakuDensity]); const danmakuCount = items.length; // 弹幕数据变化时交给官方插件渲染 @@ -352,19 +379,105 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const reader = new FileReader(); reader.onload = () => { const text = String(reader.result ?? ""); - setLocalXml(text); - writeLocalXml(mediaItemId, text); - importMeta.mutate( - { mediaItemId, byteSize: file.size }, + importXml.mutate( + { mediaItemId, xml: text, fileName: file.name }, { - onSuccess: () => toast.success(t(locale, "watch.importOk")), + onSuccess: () => { + setLocalXml(text); + writeLocalXml(mediaItemId, text); + setMatchXml(""); + toast.success(t(locale, "watch.importSaved")); + }, onError: () => toast.error(t(locale, "watch.importFail")), }, ); }; reader.readAsText(file); }, - [importMeta, mediaItemId, locale], + [importXml, mediaItemId, locale], + ); + + const openRematch = useCallback(() => { + setMatchOpen(true); + matchMut.mutate( + { mediaItemId }, + { + onError: () => { + setMatchOpen(false); + toast.error(t(locale, "watch.matchFailed")); + }, + }, + ); + }, [matchMut, mediaItemId, locale]); + + const onSelectCandidate = useCallback( + (episodeId: number) => { + selectMut.mutate( + { mediaItemId, episodeId }, + { + onSuccess: (data) => { + setMatchOpen(false); + if (data.ok && data.xml) { + setMatchXml(data.xml); + setLocalXml(""); + } + void openDanmaku.refetch(); + toast.success(t(locale, "watch.matchManual")); + }, + onError: () => toast.error(t(locale, "watch.matchFailed")), + }, + ); + }, + [selectMut, mediaItemId, locale, openDanmaku], + ); + + const activeFilters: FilterOptions | null = filterDraft + ? filterDraft + : prefs.data + ? { + blockKeywords: prefs.data.blockKeywords, + blockTypes: prefs.data.blockTypes, + } + : null; + + const addBlockKeyword = useCallback(() => { + const kw = kwInput.trim(); + if (!kw) return; + setFilterDraft((prev) => { + const base: FilterOptions = prev ?? + activeFilters ?? { blockKeywords: [], blockTypes: [] }; + if (base.blockKeywords.includes(kw)) return base; + return { ...base, blockKeywords: [...base.blockKeywords, kw] }; + }); + setKwInput(""); + }, [kwInput, activeFilters]); + + const removeBlockKeyword = useCallback( + (kw: string) => { + setFilterDraft((prev) => { + const base: FilterOptions = prev ?? + activeFilters ?? { blockKeywords: [], blockTypes: [] }; + return { ...base, blockKeywords: base.blockKeywords.filter((k) => k !== kw) }; + }); + }, + [activeFilters], + ); + + const toggleBlockType = useCallback( + (kind: "scroll" | "top" | "bottom") => { + setFilterDraft((prev) => { + const base: FilterOptions = prev ?? + activeFilters ?? { + blockKeywords: [], + blockTypes: [], + }; + const blockTypes = base.blockTypes.includes(kind) + ? base.blockTypes.filter((k) => k !== kind) + : [...base.blockTypes, kind]; + return { ...base, blockTypes }; + }); + }, + [activeFilters], ); const replay = useCallback(() => { @@ -425,19 +538,41 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { ); } + const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml"; + const fetchMessage = openDanmaku.data?.message ?? ""; + const showRematch = + danmakuOn && + !localXml && + openDanmaku.data?.source === "none" && + (fetchMessage.includes("手选") || fetchMessage.includes("未匹配")); + const candidates = matchMut.data?.candidates ?? []; + const mediaTitle = detail.data.title; + const sameNameEpisodes = new Set( + candidates + .filter( + (c) => + c.animeTitle != null && + c.animeTitle.length > 0 && + (mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)), + ) + .map((c) => c.episodeId), + ); + const sourceLabel = !danmakuOn - ? localXml + ? localXml || isLocalSource ? t(locale, "watch.danmakuDisabledHint") : t(locale, "watch.danmakuOff") - : localXml - ? t(locale, "watch.danmakuCount", { count: danmakuCount }) - : openDanmaku.data?.source === "none" - ? t(locale, "watch.noDanmakuImport") - : openDanmaku.data?.source === "cache" - ? t(locale, "watch.danmakuCache") - : openDanmaku.data?.source === "open-network" - ? t(locale, "watch.danmakuOpenNetwork") - : t(locale, "watch.danmakuLoading"); + : isLocalSource + ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}` + : matchXml + ? t(locale, "watch.danmakuOpenNetwork") + : openDanmaku.data?.source === "none" + ? t(locale, "watch.noDanmakuImport") + : openDanmaku.data?.source === "cache" + ? t(locale, "watch.danmakuCache") + : openDanmaku.data?.source === "open-network" + ? t(locale, "watch.danmakuOpenNetwork") + : t(locale, "watch.danmakuLoading"); return (
@@ -489,10 +624,157 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { )}
- {sourceLabel} + + {sourceLabel} + {showRematch && ( + + )} + {t(locale, "watch.progressHint")}
+
+

{t(locale, "watch.filterTitle")}

+
+

+ {t(locale, "danmaku.blockKeywords")} +

+
+ {(activeFilters?.blockKeywords ?? []).map((kw) => ( + + {kw} + + + ))} + setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addBlockKeyword(); + } + }} + /> + +
+

+ {t(locale, "danmaku.blockTypes")} +

+
+ {( + [ + ["scroll", "danmaku.blockTypeScroll"], + ["top", "danmaku.blockTypeTop"], + ["bottom", "danmaku.blockTypeBottom"], + ] as const + ).map(([kind, labelKey]) => { + const blocked = activeFilters?.blockTypes.includes(kind) ?? false; + return ( + + ); + })} +
+
+
+ + {matchOpen && ( +
+
+
+

+ {t(locale, "watch.matchCandidates")} +

+ +
+ {matchMut.data?.highConfidence && ( +

+ {t(locale, "watch.matchAutoBound")} +

+ )} + {matchMut.isPending && ( +

+ {t(locale, "watch.loading")} +

+ )} + {matchMut.isError && ( +

+ {t(locale, "watch.matchFailed")} +

+ )} + {matchMut.isSuccess && candidates.length === 0 && ( +

+ {t(locale, "watch.matchEmpty")} +

+ )} + {candidates.length > 0 && ( +
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} + {episodes.length > 0 && (
diff --git a/app/web/src/lib/danmaku-render.ts b/app/web/src/lib/danmaku-render.ts index 1530aaa..5bffec1 100644 --- a/app/web/src/lib/danmaku-render.ts +++ b/app/web/src/lib/danmaku-render.ts @@ -1,7 +1,18 @@ +import { + filterComments, + parseDanmakuXml, + serializeDanmakuXml, + type DanmakuComment, + type FilterOptions, +} from "@app/danmaku"; + +export { filterComments, parseDanmakuXml, serializeDanmakuXml }; +export type { DanmakuComment, FilterOptions }; + +/** artplayer-plugin-danmuku 条目(mode 0 滚动 / 1 顶部 / 2 底部) */ export type DanmakuItem = { time: number; text: string; - /** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */ mode: 0 | 1 | 2; color?: string | undefined; }; @@ -19,31 +30,19 @@ function toHexColor(decimal: number): string | undefined { return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`; } -/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */ -export function parseDanmakuXml(xml: string): DanmakuItem[] { - const doc = new DOMParser().parseFromString(xml, "application/xml"); - const nodes = Array.from(doc.querySelectorAll("d")); - const out: DanmakuItem[] = []; - for (const n of nodes) { - const p = n.getAttribute("p") ?? ""; - const [t, modeRaw, , colorRaw] = p.split(","); - const time = Number(t); - if (!Number.isFinite(time)) continue; - const text = (n.textContent ?? "").trim(); - if (!text) continue; - out.push({ - time, - text, - mode: toPluginMode(Number(modeRaw) || 1), - color: toHexColor(Number(colorRaw)), - }); - } - return out.sort((a, b) => a.time - b.time); +/** DanmakuComment(含颜色/模式)→ 插件条目 */ +export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] { + return list.map((c) => ({ + time: c.time, + text: c.text, + mode: toPluginMode(c.mode), + color: toHexColor(c.color), + })); } -/** density < 1 时按比例抽稀,避免同屏过密 */ -export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] { - if (density >= 1) return items; +/** density 0.1–1:按时间均匀抽稀 */ +export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] { + if (density >= 1) return list; const keepEvery = Math.max(1, Math.round(1 / density)); - return items.filter((_, i) => i % keepEvery === 0); + return list.filter((_, i) => i % keepEvery === 0); } diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index c415a6f..2649c62 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -175,6 +175,18 @@ export const en: Record = { "watch.playbackFinished": "Episode finished", "watch.danmakuDisabledHint": "Danmaku is off — enable it via the button above or in danmaku settings", + "watch.danmakuLocal": "Danmaku (local)", + "watch.rematch": "Rematch", + "watch.matchCandidates": "Pick episode", + "watch.matchAutoBound": "Auto-matched danmaku", + "watch.matchManual": "Manual match", + "watch.importSaved": "Danmaku saved", + "watch.foundSameName": "Linked same-name danmaku", + "watch.filterTitle": "Danmaku filters", + "watch.blockKeywordAdd": "Add block word", + "watch.matchFailed": "Danmaku match failed", + "watch.matchEmpty": "No episode candidates found", + "watch.blockKeywordPlaceholder": "Type a block word and press Enter", "danmaku.title": "Danmaku settings", "danmaku.opacity": "Opacity", @@ -186,6 +198,11 @@ export const en: Record = { "danmaku.saved": "Saved (local session)", "danmaku.localImportHint": "Import local XML on the watch page; this saves the default toggle and display options.", + "danmaku.blockKeywords": "Blocked words", + "danmaku.blockTypes": "Block types", + "danmaku.blockTypeScroll": "Scroll", + "danmaku.blockTypeTop": "Top", + "danmaku.blockTypeBottom": "Bottom", "auth.login.title": "Sign in", "auth.login.heading": "Sign in", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 09a2db0..3b40d0a 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -170,6 +170,18 @@ export const zhCN = { "watch.replay": "重新播放", "watch.playbackFinished": "本集已播放完毕", "watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启", + "watch.danmakuLocal": "弹幕(本地)", + "watch.rematch": "重新匹配", + "watch.matchCandidates": "选择弹幕集", + "watch.matchAutoBound": "已自动匹配弹幕", + "watch.matchManual": "手动匹配", + "watch.importSaved": "弹幕已保存", + "watch.foundSameName": "发现同名弹幕,已自动关联", + "watch.filterTitle": "弹幕过滤", + "watch.blockKeywordAdd": "添加屏蔽词", + "watch.matchFailed": "弹幕匹配失败", + "watch.matchEmpty": "未找到可选手集", + "watch.blockKeywordPlaceholder": "输入屏蔽词后回车", "danmaku.title": "弹幕设置", "danmaku.opacity": "不透明度", @@ -180,6 +192,11 @@ export const zhCN = { "danmaku.save": "保存", "danmaku.saved": "已保存(本地会话)", "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", + "danmaku.blockKeywords": "屏蔽词", + "danmaku.blockTypes": "类型屏蔽", + "danmaku.blockTypeScroll": "滚动", + "danmaku.blockTypeTop": "顶部", + "danmaku.blockTypeBottom": "底部", "auth.login.title": "登录", "auth.login.heading": "登录", diff --git a/yarn.lock b/yarn.lock index 17ad971..8d60186 100644 --- a/yarn.lock +++ b/yarn.lock @@ -377,6 +377,7 @@ __metadata: version: 0.0.0-use.local resolution: "@app/web@workspace:app/web" dependencies: + "@app/danmaku": "workspace:*" "@app/design-tokens": "workspace:*" "@app/i18n": "workspace:*" "@app/seo-geo": "workspace:*" From 4b2f16d779321e9cfa819a4dbd953d1bc6a6726e Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 21:13:05 +0800 Subject: [PATCH 10/17] fix(web): selectMatch toast on real payload, neutral match labels --- app/web/src/components/pages/WatchBody.tsx | 8 +++++--- packages/i18n/src/messages/en.ts | 4 ++-- packages/i18n/src/messages/zh-CN.ts | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 979a4ce..6003403 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -416,13 +416,15 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { { mediaItemId, episodeId }, { onSuccess: (data) => { - setMatchOpen(false); if (data.ok && data.xml) { + setMatchOpen(false); setMatchXml(data.xml); setLocalXml(""); + void openDanmaku.refetch(); + toast.success(t(locale, "watch.matchManual")); + return; } - void openDanmaku.refetch(); - toast.success(t(locale, "watch.matchManual")); + toast.error(data.message ?? t(locale, "watch.matchFailed")); }, onError: () => toast.error(t(locale, "watch.matchFailed")), }, diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 2649c62..a077316 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -178,10 +178,10 @@ export const en: Record = { "watch.danmakuLocal": "Danmaku (local)", "watch.rematch": "Rematch", "watch.matchCandidates": "Pick episode", - "watch.matchAutoBound": "Auto-matched danmaku", + "watch.matchAutoBound": "High-confidence candidate", "watch.matchManual": "Manual match", "watch.importSaved": "Danmaku saved", - "watch.foundSameName": "Linked same-name danmaku", + "watch.foundSameName": "Same-name candidate", "watch.filterTitle": "Danmaku filters", "watch.blockKeywordAdd": "Add block word", "watch.matchFailed": "Danmaku match failed", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 3b40d0a..2879b74 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -173,10 +173,10 @@ export const zhCN = { "watch.danmakuLocal": "弹幕(本地)", "watch.rematch": "重新匹配", "watch.matchCandidates": "选择弹幕集", - "watch.matchAutoBound": "已自动匹配弹幕", + "watch.matchAutoBound": "高置信候选", "watch.matchManual": "手动匹配", "watch.importSaved": "弹幕已保存", - "watch.foundSameName": "发现同名弹幕,已自动关联", + "watch.foundSameName": "同名候选", "watch.filterTitle": "弹幕过滤", "watch.blockKeywordAdd": "添加屏蔽词", "watch.matchFailed": "弹幕匹配失败", From 9ef9dd01a56b2dcd579fd9c6385d54841a80f7f0 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 22:47:06 +0800 Subject: [PATCH 11/17] feat(web): danmaku settings server prefs with block words --- .../components/pages/DanmakuSettingsBody.tsx | 177 +++++++++++++++--- packages/i18n/src/messages/en.ts | 9 +- packages/i18n/src/messages/zh-CN.ts | 8 +- 3 files changed, 168 insertions(+), 26 deletions(-) diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx index 5ac2e36..d729f1b 100644 --- a/app/web/src/components/pages/DanmakuSettingsBody.tsx +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -1,67 +1,147 @@ -import type { Locale } from "@app/i18n"; +import type { Locale, MessageKey } from "@app/i18n"; import { t } from "@app/i18n"; -import { Button, Card, toast } from "@app/ui"; +import { Button, Card, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { go } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; -type DanmakuSettings = { enabled: boolean; opacity: number; density: number }; +type BlockType = "scroll" | "top" | "bottom"; -const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 }; +type DanmakuSettings = { + enabled: boolean; + opacity: number; + density: number; + blockKeywords: string[]; + blockTypes: BlockType[]; +}; + +const FALLBACK: DanmakuSettings = { + enabled: true, + opacity: 0.8, + density: 1, + blockKeywords: [], + blockTypes: [], +}; + +const BLOCK_TYPE_LABELS = [ + ["scroll", "danmaku.blockTypeScroll"], + ["top", "danmaku.blockTypeTop"], + ["bottom", "danmaku.blockTypeBottom"], +] as const satisfies ReadonlyArray; + +function parseBlockTypes(raw: unknown): BlockType[] { + if (!Array.isArray(raw)) return []; + const allowed: BlockType[] = ["scroll", "top", "bottom"]; + return raw.filter((v): v is BlockType => allowed.includes(v as BlockType)); +} function readSettings(): DanmakuSettings { try { const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY); - if (!raw) return FALLBACK; + if (!raw) return { ...FALLBACK }; const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) return FALLBACK; + if (typeof parsed !== "object" || parsed === null) return { ...FALLBACK }; const rec = parsed as Record; const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled; const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity; const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density; + const kwRaw = rec["blockKeywords"]; + const blockKeywords = Array.isArray(kwRaw) + ? kwRaw.filter( + (v): v is string => typeof v === "string" && v.length > 0 && v.length <= 50, + ) + : []; return { enabled, opacity: Math.min(1, Math.max(0, opacityRaw)), density: Math.min(1, Math.max(0.1, densityRaw)), + blockKeywords: blockKeywords.slice(0, 100), + blockTypes: parseBlockTypes(rec["blockTypes"]), }; } catch { - return FALLBACK; + return { ...FALLBACK }; + } +} + +function writeSettings(next: DanmakuSettings) { + try { + localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next)); + } catch { + // localStorage 不可用时静默;服务端已持久化 } } function Inner(props: { locale: Locale }) { const { locale } = props; + const prefsQuery = trpc.media.danmakuGetSettings.useQuery(); const save = trpc.media.danmakuSaveSettings.useMutation(); - const [enabled, setEnabled] = useState(FALLBACK.enabled); - const [opacity, setOpacity] = useState(FALLBACK.opacity); - const [density, setDensity] = useState(FALLBACK.density); + const filledFromServer = useRef(false); + + const initial = readSettings(); + const [enabled, setEnabled] = useState(initial.enabled); + const [opacity, setOpacity] = useState(initial.opacity); + const [density, setDensity] = useState(initial.density); + const [blockKeywords, setBlockKeywords] = useState(initial.blockKeywords); + const [blockTypes, setBlockTypes] = useState(initial.blockTypes); + const [kwInput, setKwInput] = useState(""); useEffect(() => { - const prefs = readSettings(); - setEnabled(prefs.enabled); - setOpacity(prefs.opacity); - setDensity(prefs.density); + const data = prefsQuery.data; + if (!data || filledFromServer.current) return; + filledFromServer.current = true; + setEnabled(data.enabled); + setOpacity(data.opacity); + setDensity(data.density); + setBlockKeywords(data.blockKeywords); + setBlockTypes(data.blockTypes); + }, [prefsQuery.data]); + + const addBlockKeyword = useCallback(() => { + const kw = kwInput.trim(); + if (!kw || kw.length > 50) return; + setBlockKeywords((prev) => { + if (prev.includes(kw) || prev.length >= 100) return prev; + return [...prev, kw]; + }); + setKwInput(""); + }, [kwInput]); + + const removeBlockKeyword = useCallback((kw: string) => { + setBlockKeywords((prev) => prev.filter((k) => k !== kw)); + }, []); + + const toggleBlockType = useCallback((kind: BlockType) => { + setBlockTypes((prev) => + prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind], + ); }, []); const onSave = () => { - const next = { enabled, opacity, density }; - localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next)); + const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes }; + writeSettings(next); save.mutate(next, { onSuccess: () => { toast.success(t(locale, "danmaku.saved")); }, onError: () => { - toast.error(t(locale, "danmaku.save")); + toast.error(t(locale, "danmaku.saveFailed")); }, }); }; + const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false; + return (

{t(locale, "danmaku.title")}

+ {openNetworkMissing && ( +
+ {t(locale, "danmaku.openNetworkNotConfigured")} +
+ )}
+
+

{t(locale, "danmaku.blockKeywords")}

+
+ {blockKeywords.map((kw) => ( + + {kw} + + + ))} + setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addBlockKeyword(); + } + }} + /> + +
+
+
+

{t(locale, "danmaku.blockTypes")}

+
+ {BLOCK_TYPE_LABELS.map(([kind, labelKey]) => ( + + ))} +
+
- - {save.isSuccess && ( -

{t(locale, "danmaku.saved")}

- )}
+

+ {t(locale, "danmaku.serverPrefsHint")} +

{t(locale, "danmaku.localImportHint")}

diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index a077316..8ce6677 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -195,14 +195,19 @@ export const en: Record = { "danmaku.opacityValue": "Opacity {value}%", "danmaku.densityValue": "Density {value}%", "danmaku.save": "Save", - "danmaku.saved": "Saved (local session)", + "danmaku.saved": "Saved", + "danmaku.saveFailed": "Save failed", "danmaku.localImportHint": - "Import local XML on the watch page; this saves the default toggle and display options.", + "Import local XML on the watch page; this saves server defaults, display options, and block rules.", "danmaku.blockKeywords": "Blocked words", "danmaku.blockTypes": "Block types", "danmaku.blockTypeScroll": "Scroll", "danmaku.blockTypeTop": "Top", "danmaku.blockTypeBottom": "Bottom", + "danmaku.openNetworkNotConfigured": + "Open danmaku network credentials are not configured; online danmaku may be empty.", + "danmaku.serverPrefsHint": + "Preferences are stored on this account; watch-page filters and display follow this config.", "auth.login.title": "Sign in", "auth.login.heading": "Sign in", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 2879b74..b2a7a00 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -190,13 +190,17 @@ export const zhCN = { "danmaku.opacityValue": "不透明度 {value}%", "danmaku.densityValue": "密度 {value}%", "danmaku.save": "保存", - "danmaku.saved": "已保存(本地会话)", - "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", + "danmaku.saved": "已保存", + "danmaku.saveFailed": "保存失败", + "danmaku.localImportHint": + "本地 XML 导入在播放页操作;此处保存服务端开关、显示参数与屏蔽规则。", "danmaku.blockKeywords": "屏蔽词", "danmaku.blockTypes": "类型屏蔽", "danmaku.blockTypeScroll": "滚动", "danmaku.blockTypeTop": "顶部", "danmaku.blockTypeBottom": "底部", + "danmaku.openNetworkNotConfigured": "未配置开放弹幕网络服务端凭据,在线弹幕可能为空。", + "danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。", "auth.login.title": "登录", "auth.login.heading": "登录", From cb770d1e56d98a42c27f4d569b3d1d6a756fe30e Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 22:47:07 +0800 Subject: [PATCH 12/17] style(types): biome format media-schemas.test.ts --- packages/types/src/media-schemas.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/types/src/media-schemas.test.ts b/packages/types/src/media-schemas.test.ts index b721ced..39e2a1c 100644 --- a/packages/types/src/media-schemas.test.ts +++ b/packages/types/src/media-schemas.test.ts @@ -54,12 +54,9 @@ describe("danmakuSchemas.settings", () => { }); it("blockTypes 只接受 scroll|top|bottom", () => { expect( - danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] }) - .success, + danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] }).success, ).toBe(true); - expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe( - false, - ); + expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe(false); }); it("opacity 越界拒绝", () => { expect(danmakuSchemas.settings.safeParse({ opacity: 1.5 }).success).toBe(false); @@ -74,14 +71,11 @@ describe("danmakuSchemas.import", () => { expect(r.success).toBe(true); }); it("拒绝空 xml", () => { - expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe( - false, - ); + expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe(false); }); it("拒绝不含 { expect( - danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "no" }) - .success, + danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "no" }).success, ).toBe(false); }); it("拒绝超过 20MB 的 xml", () => { From 5b30f43252aa17352e282a2caf58f2c19d037c8d Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 22:54:56 +0800 Subject: [PATCH 13/17] fix(web): hydrate-safe danmaku settings init without render localStorage --- .../components/pages/DanmakuSettingsBody.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx index d729f1b..c1ba8c1 100644 --- a/app/web/src/components/pages/DanmakuSettingsBody.tsx +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -80,14 +80,25 @@ function Inner(props: { locale: Locale }) { const save = trpc.media.danmakuSaveSettings.useMutation(); const filledFromServer = useRef(false); - const initial = readSettings(); - const [enabled, setEnabled] = useState(initial.enabled); - const [opacity, setOpacity] = useState(initial.opacity); - const [density, setDensity] = useState(initial.density); - const [blockKeywords, setBlockKeywords] = useState(initial.blockKeywords); - const [blockTypes, setBlockTypes] = useState(initial.blockTypes); + // SSR 与水合首帧共用 FALLBACK,避免 localStorage 差异导致 hydration 不一致 + const [enabled, setEnabled] = useState(FALLBACK.enabled); + const [opacity, setOpacity] = useState(FALLBACK.opacity); + const [density, setDensity] = useState(FALLBACK.density); + const [blockKeywords, setBlockKeywords] = useState(() => []); + const [blockTypes, setBlockTypes] = useState(() => []); const [kwInput, setKwInput] = useState(""); + // 挂载后再读 localStorage 作初值/回退(客户端 only) + useEffect(() => { + const local = readSettings(); + if (filledFromServer.current) return; + setEnabled(local.enabled); + setOpacity(local.opacity); + setDensity(local.density); + setBlockKeywords(local.blockKeywords); + setBlockTypes(local.blockTypes); + }, []); + useEffect(() => { const data = prefsQuery.data; if (!data || filledFromServer.current) return; From b90d7dfc5588faa66c3515b67ce902176451ec73 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 23:35:05 +0800 Subject: [PATCH 14/17] feat(danmaku): auto-link same-name sidecar xml from mount --- app/web/src/components/pages/WatchBody.tsx | 5 + packages/i18n/src/messages/en.ts | 1 + packages/i18n/src/messages/zh-CN.ts | 1 + .../trpc/src/services/danmaku.service.test.ts | 265 +++++++++++++++++- packages/trpc/src/services/danmaku.service.ts | 70 ++++- 5 files changed, 340 insertions(+), 2 deletions(-) diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 6003403..ffb691e 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -628,6 +628,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{sourceLabel} + {isLocalSource && fetchMessage.includes("同名") && ( + + {t(locale, "watch.sidecarLinked")} + + )} {showRematch && (