app-template/docs/superpowers/plans/2026-09-26-dandanplay-capab...

1318 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 对齐弹弹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: "你好<hello>" }]);
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("你好<hello>");
const again = parseDanmakuXml(serializeDanmakuXml(list));
expect(again[0]?.text).toBe("你好<hello>");
});
});
```
- [ ] **步骤 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<string, string> {
const headers: Record<string, string> = { "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<OpenMatchOutcome> {
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<string | null> {
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
if (!res.ok) return null;
const body = res.json as { comments?: Array<Record<string, unknown>> };
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<DanmakuPrefsRow | null> {
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<DanmakuPrefsRow> {
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<DanmakuDocRow | null> {
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<DanmakuDocRow> {
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 粗算 `<d ` 个数)
2. `fetch`:当 `danmakuDocsDao.get(..., "local-xml")` 有值 → 返回 `source: "local-xml"` 且不再调网络
3. `selectMatch`:`setDanmakuMatch(..., { source: "manual" })` 后 `openCommentXml` 成功则 cache `matchKey = String(episodeId)`
- [ ] **步骤 2:确认失败**
```bash
yarn workspace @app/trpc test -- danmaku.service
```
- [ ] **步骤 3:重写 service(删掉已搬迁到 `@app/danmaku` 的纯函数)**
依赖:`@app/danmaku` 的 `openMatch`/`openCommentXml`/`headMd5`/`matchFileName`/`rankCandidates`/`hasOpenCredentials`/`HASH_HEAD_BYTES`。
核心编排(替换现有 `danmakuService`):
```ts
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
async function persistOpenXml(userId: string, mediaItemId: string, xml: string): Promise<void> {
await danmakuDocsDao.upsert({
userId,
mediaItemId,
source: "open-network",
xml,
byteSize: Buffer.byteLength(xml, "utf8"),
});
}
export const danmakuService = {
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
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<DanmakuMatchOutput> {
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<DanmakuFetchOutput> {
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("<d ")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "不是有效的弹幕 XML" });
}
await danmakuDocsDao.upsert({
userId,
mediaItemId,
source: "local-xml",
xml,
byteSize: Buffer.byteLength(xml, "utf8"),
});
return { ok: true, count: (xml.match(/<d /g) ?? []).length };
},
async getPrefs(userId: string): Promise<DanmakuSettingsOutput> {
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<DanmakuSettingsOutput> {
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<boolean> {
// 返回是否发现并登记
}
```
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 对齐。