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

46 KiB
Raw Blame History

对齐弹弹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 源

具体断言:

  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:确认失败
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 行为
  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。
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 前):

  1. item.path 同目录、同 basename、扩展名 .xml 的 WebDAV 文件(mount.listDir 或 PROPFIND 一次)
  2. 命中则读小文件全文(≤20MB)→ danmakuDocsDao.upsert(source: "local-xml")
  3. 失败静默跳过
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(mock OPEN_DANMAKU_API_BASE)

  • 步骤 1:契约 fixture 单测回归

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 或文档)
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 对齐。