46 KiB
对齐弹弹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:
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:
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:
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:运行测试确认失败
yarn workspace @app/danmaku test
预期:FAIL(包尚未创建或模块不存在)。
- 步骤 3:创建包骨架与实现
packages/danmaku/package.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 搬迁,不复制两份):
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:
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:
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:
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
yarn workspace @app/danmaku test
yarn workspace @app/danmaku typecheck
yarn workspace @app/danmaku build
预期:PASS。
- 步骤 5:Commit
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:
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:
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:运行确认失败
yarn workspace @app/danmaku test
- 步骤 3:实现 filter / match / client
packages/danmaku/src/filter.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:
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 调用):
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:测试通过
yarn workspace @app/danmaku test && yarn workspace @app/danmaku typecheck && yarn workspace @app/danmaku build
- 步骤 5:Commit
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:
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:
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 后加:
/** auto=高置信自动 | manual=手选 | none */
danmakuMatchSource: text("danmaku_match_source").notNull().default("none"),
models/src/index.ts 增加 prefs/docs 导出。
- 步骤 2:生成并推送迁移
yarn workspace @app/models build
yarn db:generate
yarn db:push
- 步骤 3:DAO
packages/dao/src/danmaku-prefs.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:
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
yarn workspace @app/models typecheck && yarn workspace @app/models build
yarn workspace @app/dao typecheck && yarn workspace @app/dao build
- 步骤 5:Commit
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 改为:
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(),
};
输出类型:
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+)
yarn workspace @app/types test && yarn workspace @app/types typecheck && yarn workspace @app/types build
- 步骤 3:Commit
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:写失败的编排测试
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 源
具体断言:
import校验媒体存在后danmakuDocsDao.upsert({ source: "local-xml", ... }),返回{ ok: true, count }(count 从 XML 粗算<d个数)fetch:当danmakuDocsDao.get(..., "local-xml")有值 → 返回source: "local-xml"且不再调网络selectMatch:setDanmakuMatch(..., { source: "manual" })后openCommentXml成功则 cachematchKey = String(episodeId)
- 步骤 2:确认失败
yarn workspace @app/trpc test -- danmaku.service
- 步骤 3:重写 service(删掉已搬迁到
@app/danmaku的纯函数)
依赖:@app/danmaku 的 openMatch/openCommentXml/headMd5/matchFileName/rankCandidates/hasOpenCredentials/HASH_HEAD_BYTES。
核心编排(替换现有 danmakuService):
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:测试通过
yarn workspace @app/trpc test && yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build
- 步骤 5:Commit
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,挂新端点
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
yarn workspace @app/trpc typecheck && yarn workspace @app/trpc build
- 步骤 3:Commit
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
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 行为
- 导入:
onFile读文本后调用trpc.media.danmakuImport.useMutation()(带xml全文),成功 toastwatch.importSaved,并setLocalXml(xml);删除仅importMeta的调用。 - 拉取优先级:
danmakuFetch返回local-xml时不再显示「暂无」;文案源见source。 - 候选手选:
danmakuFetch无弹幕且 message 含「手选」或「未匹配」时,显示「重新匹配」按钮 →danmakuMatch→ 候选 Dialog(animeTitle/episodeTitle/episodeId)→ 点选danmakuSelectMatch→ 刷新 xml。 - 过滤:从
danmakuGetSettings取blockKeywords/blockTypes,在useMemo里filterComments(parse(xml), prefs)后再thinDanmaku;播放器旁小面板可临时改本地覆盖(可选),默认读服务端 prefs。
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)
yarn dev:web
检查:导入 XML 刷新仍在;无匹配出现「重新匹配」;手选后弹幕上屏;屏蔽词生效。
- 步骤 5:Commit
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 + 手动冒烟
yarn typecheck && yarn lint
- 步骤 3:Commit
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 前):
item.path同目录、同 basename、扩展名.xml的 WebDAV 文件(mount.listDir或 PROPFIND 一次)- 命中则读小文件全文(≤20MB)→
danmakuDocsDao.upsert(source: "local-xml") - 失败静默跳过
async ensureSidecarXml(userId: string, mediaItemId: string): Promise<boolean> {
// 返回是否发现并登记
}
router 暴露可选 danmakuEnsureSidecar 或并入 danmakuFetch 前置(推荐并入 fetch,减少往返)。
- 步骤 2:WatchBody 提示
fetch 返回 local-xml 且 message 可带 foundSameName 时 toast/条幅 watch.foundSameName。
- 步骤 3:测试 + Commit
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(mockOPEN_DANMAKU_API_BASE) -
步骤 1:契约 fixture 单测回归
yarn test
yarn typecheck
yarn lint
yarn build:web
- 步骤 2:真实链路清单(人工/脚本)
- 配置
OPEN_DANMAKU_APP_ID/SECRET(或OPEN_DANMAKU_API_BASE→ mock)→ 未绑定视频自动匹配 → 弹幕上屏 - 低置信 → 手选 → 绑定刷新仍有效
- 导入 XML → 刷新/换端登录仍在
- 同目录同名 XML 自动关联
- 关联弹幕计入;屏蔽词/类型即时生效且持久
- 断网/无凭证降级:可播、本地 XML 可用
- 步骤 3:Commit 收尾(若有 e2e 或文档)
git add -A e2e docs 2>/dev/null || true
git commit -m "test(danmaku): real-chain acceptance checklist and e2e"
自检记录
-
规格覆盖度
- 协议/数据格式:任务 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
无遗漏章节。
-
占位符扫描:无 TODO/待定;sidecar 的 listDir 细节在任务 9 用现有
webdav-client/mount能力落地,不引入新抽象。 -
类型一致性:
MatchCandidate/DanmakuComment以@app/danmaku为准;DanmakuMatchOutput.candidates与 UI 一致;danmakuSchemas.import与importXml(userId, mediaItemId, xml)签名一致;setDanmakuMatch(..., { source })在任务 3/5 对齐。