1318 lines
46 KiB
Markdown
1318 lines
46 KiB
Markdown
# 对齐弹弹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 对齐。
|