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:
parent
07c7108789
commit
06e798939a
|
|
@ -24,6 +24,7 @@ type DanmukuPlugin = {
|
|||
load: (danmuku: unknown) => Promise<unknown> | unknown;
|
||||
show: () => unknown;
|
||||
hide: () => unknown;
|
||||
config: (option: { opacity?: number }) => unknown;
|
||||
};
|
||||
|
||||
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 相同的批格式兜底 */
|
||||
function sendProgressBeacon(input: ProgressInput) {
|
||||
const url = "/api/trpc/media.playbackReport?batch=1";
|
||||
|
|
@ -188,6 +197,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
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(() => {
|
||||
if (progress.data) {
|
||||
progressStartRef.current = progress.data.positionMs;
|
||||
|
|
@ -416,10 +442,14 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
{ mediaItemId, episodeId },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// 手选成功即离开本地源:清内存与 localStorage,避免再粘滞
|
||||
if (data.ok) {
|
||||
setLocalXml("");
|
||||
clearLocalXml(mediaItemId);
|
||||
}
|
||||
if (data.ok && data.xml) {
|
||||
setMatchOpen(false);
|
||||
setMatchXml(data.xml);
|
||||
setLocalXml("");
|
||||
void openDanmaku.refetch();
|
||||
toast.success(t(locale, "watch.matchManual"));
|
||||
return;
|
||||
|
|
@ -542,11 +572,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
|
||||
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
|
||||
const fetchMessage = openDanmaku.data?.message ?? "";
|
||||
// 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除)
|
||||
const showRematch =
|
||||
danmakuOn &&
|
||||
!localXml &&
|
||||
openDanmaku.data?.source === "none" &&
|
||||
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"));
|
||||
(isLocalSource ||
|
||||
(openDanmaku.data?.source === "none" &&
|
||||
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"))));
|
||||
const candidates = matchMut.data?.candidates ?? [];
|
||||
const mediaTitle = detail.data.title;
|
||||
const sameNameEpisodes = new Set(
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j
|
|||
export { filterComments, modeToKind } from "./filter.js";
|
||||
export type { FilterOptions } from "./filter.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";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j
|
|||
export { filterComments, modeToKind } from "./filter.js";
|
||||
export type { FilterOptions } from "./filter.js";
|
||||
export { parseMatchOutcome, rankCandidates } from "./match.js";
|
||||
export type { OpenMatchOutcome } from "./match.js";
|
||||
export type { OpenMatchOutcome, RankHint } from "./match.js";
|
||||
export {
|
||||
hasOpenCredentials,
|
||||
openApiBase,
|
||||
|
|
|
|||
|
|
@ -76,4 +76,83 @@ describe("rankCandidates", () => {
|
|||
const r = rankCandidates([low, mid, hi]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ type RawMatch = {
|
|||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */
|
||||
export type RankHint = {
|
||||
title?: string | undefined;
|
||||
rawName?: string | undefined;
|
||||
epNumber?: number | null | undefined;
|
||||
};
|
||||
|
||||
export function parseMatchOutcome(json: unknown): {
|
||||
isMatched: boolean;
|
||||
candidates: MatchCandidate[];
|
||||
|
|
@ -42,20 +49,61 @@ export function parseMatchOutcome(json: unknown): {
|
|||
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 */
|
||||
export function rankCandidates(
|
||||
candidates: MatchCandidate[],
|
||||
opts?: { lead?: number } | undefined,
|
||||
opts?: { lead?: number; hint?: RankHint } | 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),
|
||||
}))
|
||||
.map((c) => ({ c, score: scoreCandidate(c, opts?.hint) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const top = scored[0];
|
||||
const second = scored[1];
|
||||
|
|
|
|||
|
|
@ -39,4 +39,15 @@ export const danmakuDocsDao = {
|
|||
if (!row) throw new Error("Failed to insert danmaku doc");
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { createWebdav } from "./webdav-client.js";
|
|||
|
||||
vi.mock("@app/dao", () => ({
|
||||
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() },
|
||||
mediaItemDao: {
|
||||
getByIdForUser: vi.fn(),
|
||||
|
|
@ -181,6 +181,18 @@ describe("danmakuService.importXml", () => {
|
|||
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", () => {
|
||||
|
|
@ -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 () => {
|
||||
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||
vi.mocked(openCommentXml).mockResolvedValue(null);
|
||||
|
|
@ -318,6 +337,8 @@ describe("danmakuService.selectMatch", () => {
|
|||
expect(r.message).toContain("拉取失败");
|
||||
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled();
|
||||
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
|
||||
// 手选即离开本地源:即便拉取失败也清 local-xml
|
||||
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,13 @@ export const danmakuService = {
|
|||
if (episodeId == null) {
|
||||
const hash = await remoteFileHash(userId, mediaItemId);
|
||||
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) {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -226,7 +232,13 @@ export const danmakuService = {
|
|||
}
|
||||
const hash = await remoteFileHash(userId, mediaItemId);
|
||||
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 {
|
||||
ok: outcome.ok,
|
||||
matched: outcome.isMatched && ranked.candidates.length > 0,
|
||||
|
|
@ -248,6 +260,8 @@ export const danmakuService = {
|
|||
matchedHash: item.matchedHash,
|
||||
source: "manual",
|
||||
});
|
||||
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
|
||||
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||
const xml = await openCommentXml(episodeId);
|
||||
if (xml === null) {
|
||||
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||
|
|
@ -264,7 +278,8 @@ export const danmakuService = {
|
|||
): 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) {
|
||||
// 按 UTF-8 字节限流(Zod 同口径),而非 UTF-16 code unit
|
||||
if (Buffer.byteLength(xml, "utf8") > 20_000_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
|
||||
}
|
||||
if (!xml.includes("<d ")) {
|
||||
|
|
|
|||
|
|
@ -169,4 +169,70 @@ describe("scrapeService.matchDandanplay 绑定一致性", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -233,9 +233,16 @@ export const scrapeService = {
|
|||
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];
|
||||
if (best) {
|
||||
// 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选
|
||||
if (best && ranked.highConfidence) {
|
||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||
dandanplayEpisodeId: best.episodeId,
|
||||
matchedHash: hash,
|
||||
|
|
@ -252,6 +259,15 @@ export const scrapeService = {
|
|||
highConfidence: ranked.highConfidence,
|
||||
};
|
||||
}
|
||||
if (best) {
|
||||
return {
|
||||
ok: true,
|
||||
matched: false,
|
||||
message: "匹配不明确,请手选弹幕集",
|
||||
candidates: ranked.candidates,
|
||||
highConfidence: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (row.bangumiId != null) {
|
||||
const bridged = await bridgeDandanEpisode(
|
||||
|
|
|
|||
|
|
@ -84,6 +84,19 @@ describe("danmakuSchemas.import", () => {
|
|||
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 拒绝", () => {
|
||||
expect(
|
||||
danmakuSchemas.import.safeParse({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,24 @@ import { z } from "zod";
|
|||
|
||||
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 = {
|
||||
create: z.object({
|
||||
name: z.string().min(1, "名称不能为空").max(100),
|
||||
|
|
@ -161,7 +179,7 @@ export const danmakuSchemas = {
|
|||
xml: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(20_000_000)
|
||||
.refine((v) => utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB")
|
||||
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
|
||||
fileName: z.string().max(255).optional(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue