# 对齐弹弹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 对齐。