diff --git a/docs/superpowers/plans/2026-09-26-dandanplay-capability-alignment.md b/docs/superpowers/plans/2026-09-26-dandanplay-capability-alignment.md new file mode 100644 index 0000000..cb373ee --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-dandanplay-capability-alignment.md @@ -0,0 +1,1317 @@ +# 对齐弹弹play能力(核心链路 + 协议/数据格式)实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 新增 `@app/danmaku` 协议包对齐开放 API 与 XML/hash;补齐候选手选、弹幕持久化与同名 XML 自动关联、关联弹幕/prefs 服务端同步、关键词与类型过滤。 + +**架构:** 协议纯函数与 HTTP 适配收进独立包 `packages/danmaku`;trpc `danmaku.service` 只做编排(鉴权/落库/缓存);`@app/models` 新增 `danmaku_prefs` / `danmaku_docs` 并扩展 `media_items`;web 播放页与设置页增强,XML 解析单一实现在 `@app/danmaku`。 + +**技术栈:** TypeScript(strict)、Drizzle + Turso/libSQL、tRPC v11、Zod 4、vitest、Astro + React islands、artplayer-plugin-danmuku。 + +**规格:** `docs/superpowers/specs/2026-09-26-dandanplay-capability-alignment-design.md` + +--- + +## 文件结构 + +| 路径 | 职责 | 动作 | +|------|------|------| +| `packages/danmaku/package.json` | 包元数据、exports、scripts | 创建 | +| `packages/danmaku/tsconfig.json` / `tsconfig.build.json` | 对齐 `@app/types` 的构建 | 创建 | +| `packages/danmaku/src/signature.ts` | AppId 签名纯函数 | 创建 | +| `packages/danmaku/src/hash.ts` | 16MB MD5、matchFileName | 创建 | +| `packages/danmaku/src/xml.ts` | B 站 XML 解析/序列化 | 创建 | +| `packages/danmaku/src/filter.ts` | `filterComments`(关键词/类型) | 创建 | +| `packages/danmaku/src/match.ts` | match 入出参、`rankCandidates` 高置信 | 创建 | +| `packages/danmaku/src/client.ts` | `openFetchJson` / `openMatch` / `openCommentXml` | 创建 | +| `packages/danmaku/src/types.ts` | `DanmakuComment` / `MatchCandidate` 等 | 创建 | +| `packages/danmaku/src/index.ts` | 聚合导出 | 创建 | +| `packages/danmaku/src/*.test.ts` | 单测 + fixture | 创建 | +| `packages/models/src/danmaku-prefs.ts` | `danmaku_prefs` 表 | 创建 | +| `packages/models/src/danmaku-docs.ts` | `danmaku_docs` 表 | 创建 | +| `packages/models/src/media-items.ts` | 加 `danmakuMatchSource` | 修改 | +| `packages/models/src/index.ts` | 重导出 | 修改 | +| `packages/dao/src/danmaku-prefs.ts` | `danmakuPrefsDao` | 创建 | +| `packages/dao/src/danmaku-docs.ts` | `danmakuDocsDao` | 创建 | +| `packages/dao/src/index.ts` | 重导出 | 修改 | +| `packages/dao/src/media-items.ts` | `setDanmakuMatch` 支持 source | 修改 | +| `packages/types/src/media-schemas.ts` | prefs/import/match 候选 DTO | 修改 | +| `packages/trpc/src/services/danmaku.service.ts` | 编排 + 持久化(协议改调 `@app/danmaku`) | 修改 | +| `packages/trpc/src/services/danmaku.service.test.ts` | 编排单测 | 修改 | +| `packages/trpc/src/router/media.router.ts` | 新 procedure / settings 落库 | 修改 | +| `packages/trpc/package.json` | 依赖 `@app/danmaku` | 修改 | +| `packages/trpc/vite.config.ts` | external 加 `@app/danmaku` | 修改 | +| `app/web/src/lib/danmaku-render.ts` | 解析改用 `@app/danmaku`,加 filter 调用 | 修改 | +| `app/web/src/components/pages/WatchBody.tsx` | 候选手选、导入持久化、过滤面板 | 修改 | +| `app/web/src/components/pages/DanmakuSettingsBody.tsx` | 服务端 prefs + 屏蔽词 | 修改 | +| `app/web/package.json` | 依赖 `@app/danmaku` | 修改 | +| `packages/i18n/src/messages/zh-CN.ts` / `en.ts` | 新文案 key | 修改 | + +--- + +## 任务 1:`@app/danmaku` 包骨架 + 纯函数(签名 / 哈希 / XML) + +**文件:** +- 创建:`packages/danmaku/package.json`、`tsconfig.json`、`tsconfig.build.json` +- 创建:`packages/danmaku/src/signature.ts`、`hash.ts`、`xml.ts`、`types.ts`、`index.ts` +- 测试:`packages/danmaku/src/signature.test.ts`、`xml.test.ts`、`hash.test.ts` + +- [ ] **步骤 1:写失败的签名/哈希/XML 测试** + +`packages/danmaku/src/signature.test.ts`: + +```ts +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 { createHash } = require("node:crypto") as typeof import("node:crypto"); + const expectB64 = createHash("sha256") + .update(`app1${1700000000}/api/v2/matchsec`) + .digest("base64"); + expect(sig).toBe(expectB64); + }); +}); +``` + +`packages/danmaku/src/hash.test.ts`: + +```ts +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"); + }); +}); +``` + +`packages/danmaku/src/xml.test.ts`: + +```ts +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("你好"); + }); +}); +``` + +- [ ] **步骤 2:运行测试确认失败** + +```bash +yarn workspace @app/danmaku test +``` + +预期:FAIL(包尚未创建或模块不存在)。 + +- [ ] **步骤 3:创建包骨架与实现** + +`packages/danmaku/package.json`: + +```json +{ + "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" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^7.0.2", + "vitest": "^5.0.1" + } +} +``` + +`packages/danmaku/tsconfig.json` 复制 `packages/types/tsconfig.json`;`tsconfig.build.json` 复制 `packages/types/tsconfig.build.json`。 + +`packages/danmaku/src/signature.ts`(自现 `danmaku.service.ts` **搬迁**,不复制两份): + +```ts +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"); +} +``` + +`packages/danmaku/src/hash.ts`: + +```ts +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; +} +``` + +`packages/danmaku/src/types.ts`: + +```ts +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; +}; +``` + +`packages/danmaku/src/xml.ts`:实现 `escapeXml`、`openCommentsToXml`、`parseDanmakuXml(xml): DanmakuComment[]`、`serializeDanmakuXml(list): string`。`p` 字段格式 `time,mode,size,color,timestamp,pool,uid,rowid`;解析容错(缺省 size/color=0)。序列化输出完整 8 段。 + +`packages/danmaku/src/index.ts`: + +```ts +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"; +``` + +- [ ] **步骤 4:运行测试通过 + typecheck** + +```bash +yarn workspace @app/danmaku test +yarn workspace @app/danmaku typecheck +yarn workspace @app/danmaku build +``` + +预期:PASS。 + +- [ ] **步骤 5:Commit** + +```bash +git add packages/danmaku +git commit -m "feat(danmaku): protocol package scaffold with signature, hash, xml" +``` + +--- + +## 任务 2:`@app/danmaku` match/comment 客户端 + 过滤 + 高置信 + +**文件:** +- 创建:`packages/danmaku/src/filter.ts`、`match.ts`、`client.ts` +- 修改:`packages/danmaku/src/index.ts` +- 测试:`packages/danmaku/src/filter.test.ts`、`match.test.ts`、`client.test.ts` + +- [ ] **步骤 1:写失败测试(filter / rankCandidates / parseMatch)** + +`packages/danmaku/src/filter.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { filterComments } 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]); + }); +}); +``` + +`packages/danmaku/src/match.test.ts`: + +```ts +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); + }); +}); +``` + +- [ ] **步骤 2:运行确认失败** + +```bash +yarn workspace @app/danmaku test +``` + +- [ ] **步骤 3:实现 filter / match / client** + +`packages/danmaku/src/filter.ts`: + +```ts +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; + }); +} +``` + +`packages/danmaku/src/match.ts`: + +```ts +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 }; +} +``` + +`packages/danmaku/src/client.ts`(从 `danmaku.service.ts` **搬迁** `openFetchJson`/`openMatch`/`openCommentXml`/`openCommentsToXml` 调用): + +```ts +import { generateOpenSignature } from "./signature.js"; +import { openCommentsToXml } from "./xml.js"; +import { parseMatchOutcome, type OpenMatchOutcome } from "./match.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), + 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 ?? []); +} +``` + +`index.ts` 补导出:`filterComments`、`parseMatchOutcome`、`rankCandidates`、`openFetchJson`、`openMatch`、`openCommentXml`、`hasOpenCredentials`、`readOpenCredentials`。 + +- [ ] **步骤 4:测试通过** + +```bash +yarn workspace @app/danmaku test && yarn workspace @app/danmaku typecheck && yarn workspace @app/danmaku build +``` + +- [ ] **步骤 5:Commit** + +```bash +git add packages/danmaku +git commit -m "feat(danmaku): match/comment client, filter, high-confidence rank" +``` + +--- + +## 任务 3:数据模型 — `danmaku_prefs` / `danmaku_docs` / `danmakuMatchSource` + DAO + +**文件:** +- 创建:`packages/models/src/danmaku-prefs.ts`、`packages/models/src/danmaku-docs.ts` +- 修改:`packages/models/src/media-items.ts`、`packages/models/src/index.ts` +- 创建:`packages/dao/src/danmaku-prefs.ts`、`packages/dao/src/danmaku-docs.ts` +- 修改:`packages/dao/src/index.ts`、`packages/dao/src/media-items.ts` + +- [ ] **步骤 1:models 加表与列** + +`packages/models/src/danmaku-prefs.ts`: + +```ts +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), + opacity: integer("opacity").notNull().default(80), + 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; +``` + +> 说明:`opacity`/`density` 用整数百分比(0–100 / 10–100)存,API 层换算 0–1 小数,避免 SQLite 浮点比较坑。 + +`packages/models/src/danmaku-docs.ts`: + +```ts +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" }), + 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; +``` + +`media-items.ts` 在 `matchedHash` 后加: + +```ts + /** auto=高置信自动 | manual=手选 | none */ + danmakuMatchSource: text("danmaku_match_source").notNull().default("none"), +``` + +`models/src/index.ts` 增加 prefs/docs 导出。 + +- [ ] **步骤 2:生成并推送迁移** + +```bash +yarn workspace @app/models build +yarn db:generate +yarn db:push +``` + +- [ ] **步骤 3:DAO** + +`packages/dao/src/danmaku-prefs.ts`: + +```ts +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; + }, +}; +``` + +`packages/dao/src/danmaku-docs.ts`: + +```ts +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; + }, +}; +``` + +`media-items.ts` 的 `setDanmakuMatch` 增加 `source: "auto" | "manual"` 写入 `danmakuMatchSource`。 + +- [ ] **步骤 4:typecheck** + +```bash +yarn workspace @app/models typecheck && yarn workspace @app/models build +yarn workspace @app/dao typecheck && yarn workspace @app/dao build +``` + +- [ ] **步骤 5:Commit** + +```bash +git add packages/models packages/dao packages/db/drizzle +git commit -m "feat(models,dao): danmaku prefs/docs tables and match source" +``` + +--- + +## 任务 4:`@app/types` — Zod 与 DTO + +**文件:** +- 修改:`packages/types/src/media-schemas.ts`、`packages/types/src/media-schemas.test.ts` + +- [ ] **步骤 1:扩展 schema 与输出类型** + +`danmakuSchemas` 改为: + +```ts +export const danmakuSchemas = { + fetch: z.object({ mediaItemId: id }), + /** 手动识别:返回候选列表,不自动绑 */ + match: z.object({ mediaItemId: id }), + /** 手选绑定 episodeId */ + selectMatch: z.object({ + mediaItemId: id, + episodeId: z.number().int().positive(), + }), + /** 导入本地 XML 正文(服务端持久化) */ + import: z.object({ + mediaItemId: id, + xml: z.string().min(1).max(20_000_000), + fileName: z.string().max(255).optional(), + }), + settings: z.object({ + enabled: z.boolean().default(true), + opacity: z.number().min(0).max(1).default(0.8), + density: z.number().min(0.1).max(1).default(1), + blockKeywords: z.array(z.string().max(50)).max(100).default([]), + blockTypes: z.array(z.enum(["scroll", "top", "bottom"])).default([]), + }), + getSettings: z.object({}).optional(), +}; +``` + +输出类型: + +```ts +export type DanmakuFetchOutput = { + ok: boolean; + source: "open-network" | "cache" | "local-xml" | "none"; + xml: string; + message?: string | undefined; +}; + +export type DandanMatchResult = { + episodeId: number; + animeId: number | null; + animeTitle: string | null; + episodeTitle: string | null; + imageUrl: string | null; +}; + +export type DanmakuMatchOutput = { + ok: boolean; + matched: boolean; + message?: string | undefined; + candidates: DandanMatchResult[]; + highConfidence: boolean; +}; + +export type DanmakuSettingsOutput = { + enabled: boolean; + opacity: number; + density: number; + blockKeywords: string[]; + blockTypes: Array<"scroll" | "top" | "bottom">; + openNetworkConfigured: boolean; +}; +``` + +- [ ] **步骤 2:补/改单测**(settings 默认值、import 拒 20MB+) + +```bash +yarn workspace @app/types test && yarn workspace @app/types typecheck && yarn workspace @app/types build +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/types +git commit -m "feat(types): danmaku match candidates, import payload, prefs filters" +``` + +--- + +## 任务 5:`danmaku.service` — 走 `@app/danmaku` + 持久化 + 缓存键 + +**文件:** +- 修改:`packages/trpc/package.json`、`packages/trpc/vite.config.ts` +- 修改:`packages/trpc/src/services/danmaku.service.ts` +- 测试:`packages/trpc/src/services/danmaku.service.test.ts` + +- [ ] **步骤 1:写失败的编排测试** + +```ts +import { describe, expect, it, vi, beforeEach } from "vitest"; + +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(), + updateDanmakuMatchSource: vi.fn(), + }, + mountDao: { getByIdForUser: vi.fn() }, +})); + +// 用例:import 落 danmakuDocs;fetch 优先 local-xml;selectMatch 写 manual 源 +``` + +具体断言: + +1. `import` 校验媒体存在后 `danmakuDocsDao.upsert({ source: "local-xml", ... })`,返回 `{ ok: true, count }`(count 从 XML 粗算 ` { + await danmakuDocsDao.upsert({ + userId, + mediaItemId, + source: "open-network", + xml, + byteSize: Buffer.byteLength(xml, "utf8"), + }); +} + +export const danmakuService = { + async fetch(userId: string, mediaItemId: string): Promise { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; + + const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml"); + if (local) return { ok: true, source: "local-xml", xml: local.xml }; + + if (!hasOpenCredentials()) { + return { + ok: true, + source: "none", + xml: "", + message: "未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET", + }; + } + + const cacheKey = item.dandanplayEpisodeId != null + ? String(item.dandanplayEpisodeId) + : matchKeyFor(item.path, item.size); + const cached = await danmakuCacheDao.getValid(cacheKey); + if (cached) return { ok: true, source: "cache", xml: cached.payload }; + + let episodeId = item.dandanplayEpisodeId ?? null; + if (episodeId == null) { + const hash = await remoteFileHash(userId, mediaItemId); + const outcome = await openMatch(matchFileName(item.path), item.size, hash); + const ranked = rankCandidates(outcome.candidates); + if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { + return { + ok: true, + source: "none", + xml: "", + message: outcome.errorMessage ?? "开放网络未匹配到弹幕", + }; + } + // 仅高置信才自动绑;否则等手选(fetch 不落库) + if (!ranked.highConfidence) { + return { + ok: true, + source: "none", + xml: "", + message: "匹配不明确,请在播放页手选弹幕集", + }; + } + episodeId = ranked.candidates[0]?.episodeId ?? null; + if (episodeId == null) { + return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; + } + await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { + episodeId, + matchedHash: hash, + source: "auto", + }); + } + + const xml = await openCommentXml(episodeId); + if (xml === null) { + return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; + } + await persistOpenXml(userId, mediaItemId, xml); + await danmakuCacheDao.upsert({ + matchKey: String(episodeId), + payload: xml, + source: "open-network", + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }); + return { ok: true, source: "open-network", xml }; + }, + + async matchCandidates(userId: string, mediaItemId: string): Promise { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) return { ok: false, matched: false, message: "媒体不存在", candidates: [], highConfidence: false }; + if (!hasOpenCredentials()) { + return { + ok: false, + matched: false, + message: "未配置开放弹幕网络", + candidates: [], + highConfidence: false, + }; + } + const hash = await remoteFileHash(userId, mediaItemId); + const outcome = await openMatch(matchFileName(item.path), item.size, hash); + const ranked = rankCandidates(outcome.candidates); + return { + ok: outcome.ok, + matched: outcome.isMatched && ranked.candidates.length > 0, + message: outcome.errorMessage ?? undefined, + candidates: ranked.candidates, + highConfidence: ranked.highConfidence, + }; + }, + + async selectMatch( + userId: string, + mediaItemId: string, + episodeId: number, + ): Promise { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; + await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { + episodeId, + matchedHash: item.matchedHash, + source: "manual", + }); + const xml = await openCommentXml(episodeId); + if (xml === null) { + return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; + } + await persistOpenXml(userId, mediaItemId, xml); + await danmakuCacheDao.upsert({ + matchKey: String(episodeId), + payload: xml, + source: "open-network", + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }); + return { ok: true, source: "open-network", xml }; + }, + + async importXml( + userId: string, + mediaItemId: string, + xml: string, + ): Promise<{ ok: true; count: number }> { + const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); + if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); + if (xml.length > 20_000_000) { + throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" }); + } + if (!xml.includes(" { + const row = await danmakuPrefsDao.getByUser(userId); + return { + enabled: row?.enabled ?? true, + opacity: (row?.opacity ?? 80) / 100, + density: (row?.density ?? 100) / 100, + blockKeywords: row ? (JSON.parse(row.blockKeywords) as string[]) : [], + blockTypes: row + ? (JSON.parse(row.blockTypes) as Array<"scroll" | "top" | "bottom">) + : [], + openNetworkConfigured: hasOpenCredentials(), + }; + }, + + async savePrefs( + userId: string, + input: { + enabled: boolean; + opacity: number; + density: number; + blockKeywords: string[]; + blockTypes: Array<"scroll" | "top" | "bottom">; + }, + ): Promise { + await danmakuPrefsDao.upsert({ + userId, + enabled: input.enabled, + opacity: Math.round(input.opacity * 100), + density: Math.round(input.density * 100), + blockKeywords: JSON.stringify(input.blockKeywords), + blockTypes: JSON.stringify(input.blockTypes), + }); + return danmakuService.getPrefs(userId); + }, +}; +``` + +`packages/trpc/package.json` dependencies 加 `"@app/danmaku": "workspace:*"`;`vite.config.ts` external 数组加 `"@app/danmaku"`。 + +- [ ] **步骤 4:测试通过** + +```bash +yarn workspace @app/trpc test && yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build +``` + +- [ ] **步骤 5:Commit** + +```bash +git add packages/trpc +git commit -m "feat(trpc): danmaku service uses @app/danmaku and persists docs/prefs" +``` + +--- + +## 任务 6:router — 新 procedure 与 settings 落库 + +**文件:** +- 修改:`packages/trpc/src/router/media.router.ts` + +- [ ] **步骤 1:替换 stub,挂新端点** + +```ts + danmakuFetch: protectedProcedure + .input(danmakuSchemas.fetch) + .query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)), + danmakuMatch: protectedProcedure + .input(danmakuSchemas.match) + .mutation(({ ctx, input }) => danmakuService.matchCandidates(ctx.userId, input.mediaItemId)), + 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(({ ctx, input }) => danmakuService.savePrefs(ctx.userId, input)), +``` + +> `scrapeDandanMatch` 若仍指向 `scrapeService.matchDandanplay`,保持不动(刮削元数据用);播放页手选走 `danmakuMatch`/`danmakuSelectMatch`。 + +- [ ] **步骤 2:typecheck + build** + +```bash +yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build +``` + +- [ ] **步骤 3:Commit** + +```bash +git add packages/trpc/src/router +git commit -m "feat(trpc): danmaku match/select/import/prefs routes" +``` + +--- + +## 任务 7:web — 播放页(候选手选 / 导入持久化 / 过滤) + +**文件:** +- 修改:`app/web/src/lib/danmaku-render.ts`、`app/web/src/components/pages/WatchBody.tsx`、`app/web/package.json` +- 修改:`packages/i18n/src/messages/zh-CN.ts`、`en.ts` + +- [ ] **步骤 1:i18n key(zh-CN + en 对称)** + +``` +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" +danmaku.blockKeywords = "屏蔽词" / "Blocked words" +danmaku.blockTypes = "类型屏蔽" / "Block types" +``` + +- [ ] **步骤 2:`danmaku-render.ts` 改用 `@app/danmaku`** + +```ts +import { + parseDanmakuXml, + serializeDanmakuXml, + filterComments, + type DanmakuComment, +} from "@app/danmaku"; + +export { parseDanmakuXml, serializeDanmakuXml, filterComments }; +export type { DanmakuComment }; + +/** density 0.1–1:按时间均匀抽稀 */ +export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] { /* 保持现逻辑 */ } +``` + +`app/web/package.json` dependencies 加 `"@app/danmaku": "workspace:*"`。 + +- [ ] **步骤 3:WatchBody 行为** + +1. **导入**:`onFile` 读文本后调用 `trpc.media.danmakuImport.useMutation()`(带 `xml` 全文),成功 toast `watch.importSaved`,并 `setLocalXml(xml)`;删除仅 `importMeta` 的调用。 +2. **拉取优先级**:`danmakuFetch` 返回 `local-xml` 时不再显示「暂无」;文案源见 `source`。 +3. **候选手选**:`danmakuFetch` 无弹幕且 message 含「手选」或「未匹配」时,显示「重新匹配」按钮 → `danmakuMatch` → 候选 Dialog(`animeTitle` / `episodeTitle` / `episodeId`)→ 点选 `danmakuSelectMatch` → 刷新 xml。 +4. **过滤**:从 `danmakuGetSettings` 取 `blockKeywords`/`blockTypes`,在 `useMemo` 里 `filterComments(parse(xml), prefs)` 后再 `thinDanmaku`;播放器旁小面板可临时改本地覆盖(可选),默认读服务端 prefs。 + +```ts +const prefs = trpc.media.danmakuGetSettings.useQuery(); +const importXml = trpc.media.danmakuImport.useMutation(); +const matchMut = trpc.media.danmakuMatch.useMutation(); +const selectMut = trpc.media.danmakuSelectMatch.useMutation(); + +const items = useMemo(() => { + const raw = parseDanmakuXml(xmlSource); + const blocked = prefs.data + ? filterComments(raw, { + blockKeywords: prefs.data.blockKeywords, + blockTypes: prefs.data.blockTypes, + }) + : raw; + return thinDanmaku(blocked, danmakuDensity); +}, [xmlSource, prefs.data, danmakuDensity]); +``` + +- [ ] **步骤 4:手动冒烟(dev)** + +```bash +yarn dev:web +``` + +检查:导入 XML 刷新仍在;无匹配出现「重新匹配」;手选后弹幕上屏;屏蔽词生效。 + +- [ ] **步骤 5:Commit** + +```bash +git add app/web packages/i18n +git commit -m "feat(web): watch rematch candidates, import persist, danmaku filters" +``` + +--- + +## 任务 8:web — 弹幕设置页(服务端 prefs + 屏蔽词) + +**文件:** +- 修改:`app/web/src/components/pages/DanmakuSettingsBody.tsx` + +- [ ] **步骤 1:读写 `danmakuGetSettings` / `danmakuSaveSettings`** + +- 加载时 `useQuery` 取服务端 prefs 填表(含 `blockKeywords` 列表编辑:输入框 + 删除、`blockTypes` 三勾选)。 +- 保存按钮 → `danmakuSaveSettings` 全量提交;成功 toast;失败错误文案。 +- 去掉「已保存(本地会话)」误导文案;`openNetworkConfigured` 为 false 时显示配置提示条。 +- localStorage 仅作无会话/失败回退(保留现有 key 读取作为初值)。 + +- [ ] **步骤 2:typecheck + 手动冒烟** + +```bash +yarn typecheck && yarn lint +``` + +- [ ] **步骤 3:Commit** + +```bash +git add app/web +git commit -m "feat(web): danmaku settings server prefs with block words" +``` + +--- + +## 任务 9:同目录同名 XML 自动关联 + +**文件:** +- 修改:`packages/trpc/src/services/danmaku.service.ts`、`packages/trpc/src/router/media.router.ts` +- 修改:`app/web/src/components/pages/WatchBody.tsx` + +- [ ] **步骤 1:service `ensureSidecarXml`** + +逻辑:`fetch` 发现无 `local-xml` 且无 open 结果时(或始终在 fetch 前): + +1. `item.path` 同目录、同 basename、扩展名 `.xml` 的 WebDAV 文件(`mount.listDir` 或 PROPFIND 一次) +2. 命中则读小文件全文(≤20MB)→ `danmakuDocsDao.upsert(source: "local-xml")` +3. 失败静默跳过 + +```ts +async ensureSidecarXml(userId: string, mediaItemId: string): Promise { + // 返回是否发现并登记 +} +``` + +router 暴露可选 `danmakuEnsureSidecar` 或并入 `danmakuFetch` 前置(推荐并入 fetch,减少往返)。 + +- [ ] **步骤 2:WatchBody 提示** + +`fetch` 返回 `local-xml` 且 `message` 可带 `foundSameName` 时 toast/条幅 `watch.foundSameName`。 + +- [ ] **步骤 3:测试 + Commit** + +```bash +yarn workspace @app/trpc test +git add packages/trpc app/web packages/i18n +git commit -m "feat(danmaku): auto-link same-name sidecar xml from mount" +``` + +--- + +## 任务 10:真实链路验收 + 全仓收口 + +**文件:** +- 可选:`e2e/danmaku.spec.ts`(mock `OPEN_DANMAKU_API_BASE`) + +- [ ] **步骤 1:契约 fixture 单测回归** + +```bash +yarn test +yarn typecheck +yarn lint +yarn build:web +``` + +- [ ] **步骤 2:真实链路清单(人工/脚本)** + +1. 配置 `OPEN_DANMAKU_APP_ID/SECRET`(或 `OPEN_DANMAKU_API_BASE` → mock)→ 未绑定视频自动匹配 → 弹幕上屏 +2. 低置信 → 手选 → 绑定刷新仍有效 +3. 导入 XML → 刷新/换端登录仍在 +4. 同目录同名 XML 自动关联 +5. 关联弹幕计入;屏蔽词/类型即时生效且持久 +6. 断网/无凭证降级:可播、本地 XML 可用 + +- [ ] **步骤 3:Commit 收尾(若有 e2e 或文档)** + +```bash +git add -A e2e docs 2>/dev/null || true +git commit -m "test(danmaku): real-chain acceptance checklist and e2e" +``` + +--- + +## 自检记录 + +1. **规格覆盖度** + - 协议/数据格式:任务 1–2(签名/hash/XML/match/comment)+ 任务 5 缓存键 episodeId + - 候选手选:任务 4 DTO + 5 matchCandidates/selectMatch + 7 UI + - 持久化与自动关联:任务 3 docs + 5 importXml + 9 sidecar + - 关联弹幕与 prefs:任务 2 withRelated + 3/5/8 prefs + - 过滤屏蔽:任务 2 filterComments + 7/8 UI + - 真实链路验收:任务 10 + 无遗漏章节。 + +2. **占位符扫描**:无 TODO/待定;sidecar 的 listDir 细节在任务 9 用现有 `webdav-client`/`mount` 能力落地,不引入新抽象。 + +3. **类型一致性**:`MatchCandidate`/`DanmakuComment` 以 `@app/danmaku` 为准;`DanmakuMatchOutput.candidates` 与 UI 一致;`danmakuSchemas.import` 与 `importXml(userId, mediaItemId, xml)` 签名一致;`setDanmakuMatch(..., { source })` 在任务 3/5 对齐。