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;
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ")) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue