fix(danmaku): review wave — unstick local-xml, hint scoring, prefs sync, byte limits

- I1: selectMatch 删 local-xml + 播放页本地源可重新匹配并清 localStorage
- I2: rankCandidates 接受本地 hint,标题包含/相等与集号相等真实拉开分差
- I3: scrape.matchDandanplay 低置信不写库不覆盖标题/海报,返回候选手选
- I4: 播放页 prefs.data 到达后覆盖 danmakuOn/density/opacity,localStorage 仅回退
- I5: importXml 与 Zod 按 UTF-8 字节 ≤20MB(非 UTF-16 code unit)
This commit is contained in:
noelorin 2026-09-27 02:01:16 +08:00
parent 07c7108789
commit 06e798939a
12 changed files with 337 additions and 19 deletions

View File

@ -24,6 +24,7 @@ type DanmukuPlugin = {
load: (danmuku: unknown) => Promise<unknown> | unknown; load: (danmuku: unknown) => Promise<unknown> | unknown;
show: () => unknown; show: () => unknown;
hide: () => unknown; hide: () => unknown;
config: (option: { opacity?: number }) => unknown;
}; };
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null { function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
@ -89,6 +90,14 @@ function writeLocalXml(mediaItemId: string, xml: string) {
} }
} }
function clearLocalXml(mediaItemId: string) {
try {
localStorage.removeItem(DANMAKU_XML_PREFIX + mediaItemId);
} catch {
/* noop */
}
}
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */ /** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
function sendProgressBeacon(input: ProgressInput) { function sendProgressBeacon(input: ProgressInput) {
const url = "/api/trpc/media.playbackReport?batch=1"; const url = "/api/trpc/media.playbackReport?batch=1";
@ -188,6 +197,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
setDanmakuDensity(prefs.density); setDanmakuDensity(prefs.density);
}, []); }, []);
// 服务端 prefs 到达后覆盖显示参数;localStorage 仅作 query 失败回退
useEffect(() => {
const data = prefs.data;
if (!data) return;
const next: DanmakuPrefs = {
enabled: data.enabled,
opacity: Math.min(1, Math.max(0, data.opacity)),
density: Math.min(1, Math.max(0.1, data.density)),
};
prefsRef.current = next;
setDanmakuOn(next.enabled);
setDanmakuDensity(next.density);
const art = artRef.current;
const dm = art ? danmukuPlugin(art) : null;
if (dm) dm.config({ opacity: next.opacity });
}, [prefs.data]);
useEffect(() => { useEffect(() => {
if (progress.data) { if (progress.data) {
progressStartRef.current = progress.data.positionMs; progressStartRef.current = progress.data.positionMs;
@ -416,10 +442,14 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{ mediaItemId, episodeId }, { mediaItemId, episodeId },
{ {
onSuccess: (data) => { onSuccess: (data) => {
// 手选成功即离开本地源:清内存与 localStorage,避免再粘滞
if (data.ok) {
setLocalXml("");
clearLocalXml(mediaItemId);
}
if (data.ok && data.xml) { if (data.ok && data.xml) {
setMatchOpen(false); setMatchOpen(false);
setMatchXml(data.xml); setMatchXml(data.xml);
setLocalXml("");
void openDanmaku.refetch(); void openDanmaku.refetch();
toast.success(t(locale, "watch.matchManual")); toast.success(t(locale, "watch.matchManual"));
return; return;
@ -542,11 +572,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml"; const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
const fetchMessage = openDanmaku.data?.message ?? ""; const fetchMessage = openDanmaku.data?.message ?? "";
// 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除)
const showRematch = const showRematch =
danmakuOn && danmakuOn &&
!localXml && (isLocalSource ||
openDanmaku.data?.source === "none" && (openDanmaku.data?.source === "none" &&
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配")); (fetchMessage.includes("手选") || fetchMessage.includes("未匹配"))));
const candidates = matchMut.data?.candidates ?? []; const candidates = matchMut.data?.candidates ?? [];
const mediaTitle = detail.data.title; const mediaTitle = detail.data.title;
const sameNameEpisodes = new Set( const sameNameEpisodes = new Set(

View File

@ -3,5 +3,5 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j
export { filterComments, modeToKind } from "./filter.js"; export { filterComments, modeToKind } from "./filter.js";
export type { FilterOptions } from "./filter.js"; export type { FilterOptions } from "./filter.js";
export { parseMatchOutcome, rankCandidates } from "./match.js"; export { parseMatchOutcome, rankCandidates } from "./match.js";
export type { OpenMatchOutcome } from "./match.js"; export type { OpenMatchOutcome, RankHint } from "./match.js";
export type { DanmakuComment, MatchCandidate } from "./types.js"; export type { DanmakuComment, MatchCandidate } from "./types.js";

View File

@ -4,7 +4,7 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j
export { filterComments, modeToKind } from "./filter.js"; export { filterComments, modeToKind } from "./filter.js";
export type { FilterOptions } from "./filter.js"; export type { FilterOptions } from "./filter.js";
export { parseMatchOutcome, rankCandidates } from "./match.js"; export { parseMatchOutcome, rankCandidates } from "./match.js";
export type { OpenMatchOutcome } from "./match.js"; export type { OpenMatchOutcome, RankHint } from "./match.js";
export { export {
hasOpenCredentials, hasOpenCredentials,
openApiBase, openApiBase,

View File

@ -76,4 +76,83 @@ describe("rankCandidates", () => {
const r = rankCandidates([low, mid, hi]); const r = rankCandidates([low, mid, hi]);
expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]); expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]);
}); });
it("本地标题包含命中加分:多候选时命中者高置信", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, { hint: { title: "葬送的芙莉莲" } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(2);
});
it("本地标题完全相等加分高于部分包含", () => {
const list = [
{
episodeId: 1,
animeId: 1,
animeTitle: "命运石之门",
episodeTitle: "01",
imageUrl: null,
},
{
episodeId: 2,
animeId: 2,
animeTitle: "命运石之门 负荷领域",
episodeTitle: "01",
imageUrl: null,
},
];
// 本地标题「命运石之门」:候选 1 完全相等(+1),候选 2 为包含(+0.6)
const r = rankCandidates(list, { hint: { title: "命运石之门" } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(1);
});
it("rawName 包含候选标题也加分", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, {
hint: { title: "本地标题", rawName: "[Sub] Sousou no Frieren - 01.mkv" },
});
// rawName 不含任一候选,无加分 → 同分不高置信
expect(r.highConfidence).toBe(false);
const r2 = rankCandidates(list, {
hint: { title: "x", rawName: "[Sub] 芙莉莲 - 01.mkv" },
});
expect(r2.highConfidence).toBe(true);
expect(r2.candidates[0]?.episodeId).toBe(2);
});
it("单字符候选名不参与包含命中,避免噪声加分", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null },
];
const r = rankCandidates(list, {
hint: { title: "Title", rawName: "Title - 01.mkv", epNumber: 1 },
});
expect(r.highConfidence).toBe(false);
});
it("集号相等加分拉开同标题候选差距", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "02", imageUrl: null },
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, { hint: { title: "Show", epNumber: 1 } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(2);
});
it("无 hint 时保持字段存在性打分(默认 lead 0.15)", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
expect(rankCandidates(list).highConfidence).toBe(false);
});
}); });

View File

@ -15,6 +15,13 @@ type RawMatch = {
imageUrl?: string | null; imageUrl?: string | null;
}; };
/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */
export type RankHint = {
title?: string | undefined;
rawName?: string | undefined;
epNumber?: number | null | undefined;
};
export function parseMatchOutcome(json: unknown): { export function parseMatchOutcome(json: unknown): {
isMatched: boolean; isMatched: boolean;
candidates: MatchCandidate[]; candidates: MatchCandidate[];
@ -42,20 +49,61 @@ export function parseMatchOutcome(json: unknown): {
return { isMatched: body.isMatched === true, candidates }; return { isMatched: body.isMatched === true, candidates };
} }
/** 归一化标题:小写并去掉空白/标点,便于包含关系比对 */
function normalizeText(s: string): string {
return s.toLowerCase().replace(/[\s\-_·・.。!!??:::、,,'"「」『』()()【】[\]]/g, "");
}
/**
* 标题命中加分:完全相等 > 相互包含。
* 双方归一化后至少 2 字符才算命中,避免 "T" 命中 "Title" 这类噪声。
*/
function titleBonus(local: string | undefined, candidate: string | null): number {
if (!local || !candidate) return 0;
const a = normalizeText(local);
const b = normalizeText(candidate);
if (a.length < 2 || b.length < 2) return 0;
if (a === b) return 1;
if (a.includes(b) || b.includes(a)) return 0.6;
return 0;
}
function firstInt(s: string | null): number | null {
if (!s) return null;
const m = /\d+/.exec(s);
const raw = m?.[0];
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/** 集号相等加分(episodeTitle 中首个数字与本地 epNumber 相等) */
function episodeBonus(hint: RankHint | undefined, episodeTitle: string | null): number {
const ep = hint?.epNumber;
if (ep == null || !episodeTitle) return 0;
return firstInt(episodeTitle) === ep ? 0.5 : 0;
}
function scoreCandidate(c: MatchCandidate, hint: RankHint | undefined): number {
const base = (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0);
if (!hint) return base;
const matched = Math.max(
titleBonus(hint.title, c.animeTitle),
titleBonus(hint.rawName, c.animeTitle),
);
return base + matched + episodeBonus(hint, c.episodeTitle);
}
/** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */ /** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */
export function rankCandidates( export function rankCandidates(
candidates: MatchCandidate[], candidates: MatchCandidate[],
opts?: { lead?: number } | undefined, opts?: { lead?: number; hint?: RankHint } | undefined,
): { candidates: MatchCandidate[]; highConfidence: boolean } { ): { candidates: MatchCandidate[]; highConfidence: boolean } {
const lead = opts?.lead ?? 0.15; const lead = opts?.lead ?? 0.15;
if (candidates.length === 0) return { candidates: [], highConfidence: false }; if (candidates.length === 0) return { candidates: [], highConfidence: false };
if (candidates.length === 1) return { candidates, highConfidence: true }; if (candidates.length === 1) return { candidates, highConfidence: true };
const scored = candidates const scored = candidates
.map((c) => ({ .map((c) => ({ c, score: scoreCandidate(c, opts?.hint) }))
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); .sort((a, b) => b.score - a.score);
const top = scored[0]; const top = scored[0];
const second = scored[1]; const second = scored[1];

View File

@ -39,4 +39,15 @@ export const danmakuDocsDao = {
if (!row) throw new Error("Failed to insert danmaku doc"); if (!row) throw new Error("Failed to insert danmaku doc");
return row; return row;
}, },
async remove(userId: string, mediaItemId: string, source: string): Promise<void> {
await db
.delete(danmakuDocs)
.where(
and(
eq(danmakuDocs.userId, userId),
eq(danmakuDocs.mediaItemId, mediaItemId),
eq(danmakuDocs.source, source),
),
);
},
}; };

View File

@ -13,7 +13,7 @@ import { createWebdav } from "./webdav-client.js";
vi.mock("@app/dao", () => ({ vi.mock("@app/dao", () => ({
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() }, danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
danmakuDocsDao: { get: vi.fn(), upsert: vi.fn() }, danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() },
danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() }, danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() },
mediaItemDao: { mediaItemDao: {
getByIdForUser: vi.fn(), getByIdForUser: vi.fn(),
@ -181,6 +181,18 @@ describe("danmakuService.importXml", () => {
code: "BAD_REQUEST", code: "BAD_REQUEST",
}); });
}); });
it("按 UTF-8 字节限 20MB:code unit 未超但字节超限拒绝", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
// 7_000_000 个汉字 = 7M code units(旧实现放行)但 21MB UTF-8
const huge = `<i><d>${"中".repeat(7_000_000)}</d></i>`;
expect(huge.length).toBeLessThanOrEqual(20_000_000);
await expect(danmakuService.importXml("u1", "m1", huge)).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "XML 过大",
});
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
});
}); });
describe("danmakuService.fetch", () => { describe("danmakuService.fetch", () => {
@ -310,6 +322,13 @@ describe("danmakuService.selectMatch", () => {
); );
}); });
it("成功后清除 local-xml,避免本地源粘滞", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
await danmakuService.selectMatch("u1", "m1", 4242);
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
});
it("拉取失败仍保留 manual 绑定,返回 none", async () => { it("拉取失败仍保留 manual 绑定,返回 none", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(openCommentXml).mockResolvedValue(null); vi.mocked(openCommentXml).mockResolvedValue(null);
@ -318,6 +337,8 @@ describe("danmakuService.selectMatch", () => {
expect(r.message).toContain("拉取失败"); expect(r.message).toContain("拉取失败");
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled(); expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled();
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
// 手选即离开本地源:即便拉取失败也清 local-xml
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
}); });
}); });

View File

@ -166,7 +166,13 @@ export const danmakuService = {
if (episodeId == null) { if (episodeId == null) {
const hash = await remoteFileHash(userId, mediaItemId); const hash = await remoteFileHash(userId, mediaItemId);
const outcome = await openMatch(matchFileName(item.path), item.size, hash); const outcome = await openMatch(matchFileName(item.path), item.size, hash);
const ranked = rankCandidates(outcome.candidates); const ranked = rankCandidates(outcome.candidates, {
hint: {
title: item.title,
rawName: item.rawName,
epNumber: item.epNumber ?? null,
},
});
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
return { return {
ok: true, ok: true,
@ -226,7 +232,13 @@ export const danmakuService = {
} }
const hash = await remoteFileHash(userId, mediaItemId); const hash = await remoteFileHash(userId, mediaItemId);
const outcome = await openMatch(matchFileName(item.path), item.size, hash); const outcome = await openMatch(matchFileName(item.path), item.size, hash);
const ranked = rankCandidates(outcome.candidates); const ranked = rankCandidates(outcome.candidates, {
hint: {
title: item.title,
rawName: item.rawName,
epNumber: item.epNumber ?? null,
},
});
return { return {
ok: outcome.ok, ok: outcome.ok,
matched: outcome.isMatched && ranked.candidates.length > 0, matched: outcome.isMatched && ranked.candidates.length > 0,
@ -248,6 +260,8 @@ export const danmakuService = {
matchedHash: item.matchedHash, matchedHash: item.matchedHash,
source: "manual", source: "manual",
}); });
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
const xml = await openCommentXml(episodeId); const xml = await openCommentXml(episodeId);
if (xml === null) { if (xml === null) {
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
@ -264,7 +278,8 @@ export const danmakuService = {
): Promise<{ ok: true; count: number }> { ): Promise<{ ok: true; count: number }> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
if (xml.length > 20_000_000) { // 按 UTF-8 字节限流(Zod 同口径),而非 UTF-16 code unit
if (Buffer.byteLength(xml, "utf8") > 20_000_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" }); throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
} }
if (!xml.includes("<d ")) { if (!xml.includes("<d ")) {

View File

@ -169,4 +169,70 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => {
expect.objectContaining({ dandanplayEpisodeId: 22 }), expect.objectContaining({ dandanplayEpisodeId: 22 }),
); );
}); });
it("本地标题命中拉大差距 → 多候选仍高置信自动绑", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
{
episodeId: 11,
animeId: 1,
animeTitle: "Other",
episodeTitle: "X",
imageUrl: null,
},
{
episodeId: 22,
animeId: 2,
animeTitle: "Show",
episodeTitle: "X",
imageUrl: null,
},
],
});
const r = await scrapeService.matchDandanplay("u1", "m1");
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(22);
expect(mediaItemDao.updateScrape).toHaveBeenCalledWith(
"m1",
"u1",
expect.objectContaining({ dandanplayEpisodeId: 22 }),
);
});
it("低置信多候选:不写库(不覆盖标题/海报),返回候选供手选", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
mediaItem({ title: "Keep Me", posterUrl: "https://img/keep.jpg" }),
);
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
{
episodeId: 11,
animeId: 1,
animeTitle: "AAA",
episodeTitle: "X",
imageUrl: "a.jpg",
},
{
episodeId: 22,
animeId: 2,
animeTitle: "BBB",
episodeTitle: "Y",
imageUrl: "b.jpg",
},
],
});
const r = await scrapeService.matchDandanplay("u1", "m1");
expect(r.highConfidence).toBe(false);
expect(r.matched).toBe(false);
expect(r.candidates).toHaveLength(2);
expect(r.message).toContain("手选");
expect(mediaItemDao.updateScrape).not.toHaveBeenCalled();
});
}); });

View File

@ -233,9 +233,16 @@ export const scrapeService = {
highConfidence: false, highConfidence: false,
}; };
} }
const ranked = rankCandidates(outcome.candidates); const ranked = rankCandidates(outcome.candidates, {
hint: {
title: row.title,
rawName: row.rawName,
epNumber: row.epNumber ?? null,
},
});
const best = ranked.candidates[0]; const best = ranked.candidates[0];
if (best) { // 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选
if (best && ranked.highConfidence) {
await mediaItemDao.updateScrape(mediaItemId, userId, { await mediaItemDao.updateScrape(mediaItemId, userId, {
dandanplayEpisodeId: best.episodeId, dandanplayEpisodeId: best.episodeId,
matchedHash: hash, matchedHash: hash,
@ -252,6 +259,15 @@ export const scrapeService = {
highConfidence: ranked.highConfidence, highConfidence: ranked.highConfidence,
}; };
} }
if (best) {
return {
ok: true,
matched: false,
message: "匹配不明确,请手选弹幕集",
candidates: ranked.candidates,
highConfidence: false,
};
}
if (row.bangumiId != null) { if (row.bangumiId != null) {
const bridged = await bridgeDandanEpisode( const bridged = await bridgeDandanEpisode(

View File

@ -84,6 +84,19 @@ describe("danmakuSchemas.import", () => {
false, false,
); );
}); });
it("按 UTF-8 字节限 20MB:多字节字符 code unit 未超但字节超限拒绝", () => {
// 7_000_000 个汉字 = 7M code units(旧 .max(20_000_000) 放行)但 21MB UTF-8
const huge = `<d p="1,1,0,0,0,0,,">${"中".repeat(7_000_000)}</d>`;
expect(huge.length).toBeLessThanOrEqual(20_000_000);
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
false,
);
});
it("多字节字符字节未超 20MB 时放行", () => {
// 6_000_000 个汉字 ≈ 18MB UTF-8,code units 仅 6M
const ok = `<d p="1,1,0,0,0,0,,">${"中".repeat(6_000_000)}</d>`;
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: ok }).success).toBe(true);
});
it("fileName 超 255 拒绝", () => { it("fileName 超 255 拒绝", () => {
expect( expect(
danmakuSchemas.import.safeParse({ danmakuSchemas.import.safeParse({

View File

@ -2,6 +2,24 @@ import { z } from "zod";
const id = z.string().min(1); const id = z.string().min(1);
/** UTF-8 字节数(等价 TextEncoder/Buffer.byteLength,纯实现,浏览器/Node 均可用) */
function utf8ByteLength(s: string): number {
let bytes = 0;
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff) {
bytes += 4;
i += 1;
} else bytes += 3;
}
return bytes;
}
/** 导入/侧车 XML 全文上限:20MB(UTF-8 字节) */
const XML_MAX_BYTES = 20_000_000;
export const mountSchemas = { export const mountSchemas = {
create: z.object({ create: z.object({
name: z.string().min(1, "名称不能为空").max(100), name: z.string().min(1, "名称不能为空").max(100),
@ -161,7 +179,7 @@ export const danmakuSchemas = {
xml: z xml: z
.string() .string()
.min(1) .min(1)
.max(20_000_000) .refine((v) => utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB")
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"), .refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
fileName: z.string().max(255).optional(), fileName: z.string().max(255).optional(),
}), }),