feat(types): danmaku match candidates, import payload, prefs filters

This commit is contained in:
noelorin 2026-09-26 19:40:43 +08:00
parent 3467e397e7
commit aab636eac7
2 changed files with 97 additions and 9 deletions

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js";
import { danmakuSchemas, mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js";
describe("mountSchemas.create", () => {
it("拒绝缺 baseUrl", () => {
@ -38,3 +38,76 @@ describe("playbackSchemas.report", () => {
).toBe(false);
});
});
describe("danmakuSchemas.settings", () => {
it("缺省时填充默认值", () => {
const r = danmakuSchemas.settings.safeParse({});
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data).toEqual({
enabled: true,
opacity: 0.8,
density: 1,
blockKeywords: [],
blockTypes: [],
});
});
it("blockTypes 只接受 scroll|top|bottom", () => {
expect(
danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] })
.success,
).toBe(true);
expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe(
false,
);
});
it("opacity 越界拒绝", () => {
expect(danmakuSchemas.settings.safeParse({ opacity: 1.5 }).success).toBe(false);
});
});
describe("danmakuSchemas.import", () => {
const validXml = `<?xml version="1.0"?><i><d p="0">hi</d></i>`;
it("接受含 <d 的弹幕 XML", () => {
const r = danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: validXml });
expect(r.success).toBe(true);
});
it("拒绝空 xml", () => {
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe(
false,
);
});
it("拒绝不含 <d 的非弹幕内容", () => {
expect(
danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "<html>no</html>" })
.success,
).toBe(false);
});
it("拒绝超过 20MB 的 xml", () => {
const huge = `<d ${"a".repeat(20_000_000)}`;
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
false,
);
});
it("fileName 超 255 拒绝", () => {
expect(
danmakuSchemas.import.safeParse({
mediaItemId: "m1",
xml: validXml,
fileName: "a".repeat(256),
}).success,
).toBe(false);
});
});
describe("danmakuSchemas.selectMatch", () => {
it("要求正整数 episodeId", () => {
expect(
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 42 }).success,
).toBe(true);
expect(
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 0 }).success,
).toBe(false);
});
});

View File

@ -148,17 +148,29 @@ export const scrapeSchemas = {
export const danmakuSchemas = {
/** 开放网络:按媒体文件取弹幕 */
fetch: z.object({ mediaItemId: id }),
/** 手动弹弹play 文件识别(match),命中后写刮削元数据 + 弹幕身份 */
/** 手动识别:返回候选列表,不自动绑 */
match: z.object({ mediaItemId: id }),
/** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */
importMeta: z.object({
/** 手选绑定 episodeId */
selectMatch: z.object({
mediaItemId: id,
byteSize: z.number().min(0).max(20_000_000),
episodeId: z.number().int().positive(),
}),
/** 导入本地 XML 正文(服务端持久化);粗检含弹幕 <d 节点 */
import: z.object({
mediaItemId: id,
xml: z
.string()
.min(1)
.max(20_000_000)
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
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(),
};
@ -183,10 +195,10 @@ export type BatchResult = {
};
export type DanmakuFetchOutput = {
ok: boolean;
source: "open-network" | "cache" | "none";
source: "open-network" | "cache" | "local-xml" | "none";
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
xml: string;
message?: string;
message?: string | undefined;
};
/** 弹弹play match 命中的剧集(MatchResultV2 的刮削子集) */
@ -201,14 +213,17 @@ export type DandanMatchResult = {
export type DanmakuMatchOutput = {
ok: boolean;
matched: boolean;
message?: string;
result?: DandanMatchResult;
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;
};