feat(danmaku): auto-link same-name sidecar xml from mount

This commit is contained in:
noelorin 2026-09-26 23:35:05 +08:00
parent 5b30f43252
commit b90d7dfc55
5 changed files with 340 additions and 2 deletions

View File

@ -628,6 +628,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span>{sourceLabel}</span> <span>{sourceLabel}</span>
{isLocalSource && fetchMessage.includes("同名") && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
{t(locale, "watch.sidecarLinked")}
</span>
)}
{showRematch && ( {showRematch && (
<Button size="sm" variant="outline" onClick={openRematch}> <Button size="sm" variant="outline" onClick={openRematch}>
{t(locale, "watch.rematch")} {t(locale, "watch.rematch")}

View File

@ -182,6 +182,7 @@ export const en: Record<MessageKey, string> = {
"watch.matchManual": "Manual match", "watch.matchManual": "Manual match",
"watch.importSaved": "Danmaku saved", "watch.importSaved": "Danmaku saved",
"watch.foundSameName": "Same-name candidate", "watch.foundSameName": "Same-name candidate",
"watch.sidecarLinked": "Same-name danmaku",
"watch.filterTitle": "Danmaku filters", "watch.filterTitle": "Danmaku filters",
"watch.blockKeywordAdd": "Add block word", "watch.blockKeywordAdd": "Add block word",
"watch.matchFailed": "Danmaku match failed", "watch.matchFailed": "Danmaku match failed",

View File

@ -177,6 +177,7 @@ export const zhCN = {
"watch.matchManual": "手动匹配", "watch.matchManual": "手动匹配",
"watch.importSaved": "弹幕已保存", "watch.importSaved": "弹幕已保存",
"watch.foundSameName": "同名候选", "watch.foundSameName": "同名候选",
"watch.sidecarLinked": "同名弹幕",
"watch.filterTitle": "弹幕过滤", "watch.filterTitle": "弹幕过滤",
"watch.blockKeywordAdd": "添加屏蔽词", "watch.blockKeywordAdd": "添加屏蔽词",
"watch.matchFailed": "弹幕匹配失败", "watch.matchFailed": "弹幕匹配失败",

View File

@ -1,13 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku"; import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku";
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao } from "@app/dao"; import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
import type { import type {
DanmakuCacheRow, DanmakuCacheRow,
DanmakuDocRow, DanmakuDocRow,
DanmakuPrefsRow, DanmakuPrefsRow,
MediaItem as MediaItemRow, MediaItem as MediaItemRow,
Mount,
} from "@app/models"; } from "@app/models";
import { danmakuService } from "./danmaku.service.js"; import { danmakuService } from "./danmaku.service.js";
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() },
@ -20,6 +22,14 @@ vi.mock("@app/dao", () => ({
mountDao: { getByIdForUser: vi.fn() }, mountDao: { getByIdForUser: vi.fn() },
})); }));
vi.mock("./webdav-client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./webdav-client.js")>();
return {
...actual,
createWebdav: vi.fn(),
};
});
vi.mock("@app/danmaku", async (importOriginal) => { vi.mock("@app/danmaku", async (importOriginal) => {
const actual = await importOriginal<typeof import("@app/danmaku")>(); const actual = await importOriginal<typeof import("@app/danmaku")>();
return { return {
@ -94,6 +104,49 @@ function prefsRow(over: Partial<DanmakuPrefsRow> = {}): DanmakuPrefsRow {
}; };
} }
function mountRow(over: Partial<Mount> = {}): Mount {
return {
id: "mnt1",
userId: "u1",
name: "nas",
type: "webdav",
baseUrl: "https://dav.example.com",
username: null,
secretEnc: "",
rootPath: "/library",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
...over,
};
}
type FakeEntry = {
filename: string;
basename: string;
type: "file" | "directory";
size: number;
};
function fakeWebdavClient(opts: {
entries?: FakeEntry[];
contents?: string;
listError?: Error;
readError?: Error;
}) {
return {
getDirectoryContents: vi.fn().mockImplementation(async () => {
if (opts.listError) throw opts.listError;
return opts.entries ?? [];
}),
getFileContents: vi.fn().mockImplementation(async () => {
if (opts.readError) throw opts.readError;
return Buffer.from(opts.contents ?? "", "utf8");
}),
createReadStream: vi.fn(),
};
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -364,3 +417,213 @@ describe("openCommentsToXml(@app/danmaku,p 序列化 8 段)", () => {
).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>'); ).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>');
}); });
}); });
describe("danmakuService.ensureSidecarXml", () => {
const sidecarXml = '<i><d p="1,1,0,0,0,0,,">hi</d></i>';
function setupMounted(over: Partial<MediaItemRow> = {}) {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
mediaItem({ mountId: "mnt1", ...over }),
);
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
}
it("同目录同名 .xml 侧车:读全文落 local-xml,返回 true", async () => {
setupMounted({ path: "/anime/[Sub] Title - 01.mkv" });
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({
entries: [
{
filename: "/library/anime/[Sub] Title - 01.xml",
basename: "[Sub] Title - 01.xml",
type: "file",
size: sidecarXml.length,
},
],
contents: sidecarXml,
}) as never,
);
const ok = await danmakuService.ensureSidecarXml("u1", "m1");
expect(ok).toBe(true);
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith({
userId: "u1",
mediaItemId: "m1",
source: "local-xml",
xml: sidecarXml,
byteSize: Buffer.byteLength(sidecarXml, "utf8"),
});
});
it("扩展名 .XML 大小写不敏感同样命中", async () => {
setupMounted({ path: "/anime/Show - 02.mkv" });
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({
entries: [
{
filename: "/library/anime/SHOW - 02.XML",
basename: "SHOW - 02.XML",
type: "file",
size: sidecarXml.length,
},
],
contents: sidecarXml,
}) as never,
);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(true);
});
it("仅同 basename 的 .xml 命中:别的名/别的扩展/目录均跳过", async () => {
setupMounted({ path: "/anime/Show - 02.mkv" });
const client = fakeWebdavClient({
entries: [
{
filename: "/library/anime/Show - 03.xml",
basename: "Show - 03.xml",
type: "file",
size: 10,
},
{
filename: "/library/anime/Show - 02.srt",
basename: "Show - 02.srt",
type: "file",
size: 10,
},
{
filename: "/library/anime/Show - 02.xml",
basename: "Show - 02.xml",
type: "directory",
size: 0,
},
],
contents: sidecarXml,
});
vi.mocked(createWebdav).mockReturnValue(client as never);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
expect(client.getFileContents).not.toHaveBeenCalled();
});
it("侧车超过 20MB:不读文件直接跳过", async () => {
setupMounted({ path: "/anime/Show - 02.mkv" });
const client = fakeWebdavClient({
entries: [
{
filename: "/library/anime/Show - 02.xml",
basename: "Show - 02.xml",
type: "file",
size: 20_000_001,
},
],
contents: sidecarXml,
});
vi.mocked(createWebdav).mockReturnValue(client as never);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
expect(client.getFileContents).not.toHaveBeenCalled();
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
});
it("非弹幕粗检(无 <d )跳过", async () => {
setupMounted({ path: "/anime/Show - 02.mkv" });
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({
entries: [
{
filename: "/library/anime/Show - 02.xml",
basename: "Show - 02.xml",
type: "file",
size: 20,
},
],
contents: "<html>not danmaku</html>",
}) as never,
);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
});
it("列目录/读取失败静默跳过不抛", async () => {
setupMounted({ path: "/anime/Show - 02.mkv" });
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({ listError: new Error("dav down") }) as never,
);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({
entries: [
{
filename: "/library/anime/Show - 02.xml",
basename: "Show - 02.xml",
type: "file",
size: 20,
},
],
readError: new Error("boom"),
}) as never,
);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
});
it("媒体无挂载/挂载失效:返回 false 且不建客户端", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: null }));
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: "mnt1" }));
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(null);
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
expect(createWebdav).not.toHaveBeenCalled();
});
});
describe("danmakuService.fetch 同名侧车", () => {
const sidecarXml = '<i><d p="1,1,0,0,0,0,,">side</d></i>';
it("无 local-xml 时自动关联侧车:返回 local-xml 且 message 含同名提示", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
mediaItem({ mountId: "mnt1", path: "/anime/Show - 01.mkv" }),
);
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
vi.mocked(createWebdav).mockReturnValue(
fakeWebdavClient({
entries: [
{
filename: "/library/anime/Show - 01.xml",
basename: "Show - 01.xml",
type: "file",
size: sidecarXml.length,
},
],
contents: sidecarXml,
}) as never,
);
vi.mocked(danmakuDocsDao.get)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(docRow({ xml: sidecarXml }));
const r = await danmakuService.fetch("u1", "m1");
expect(r.ok).toBe(true);
expect(r.source).toBe("local-xml");
expect(r.xml).toBe(sidecarXml);
expect(r.message).toContain("同名");
expect(openMatch).not.toHaveBeenCalled();
});
it("侧车未命中时继续走开放网络前置(缺配置时明示)", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: "mnt1" }));
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
vi.mocked(createWebdav).mockReturnValue(fakeWebdavClient({ entries: [] }) as never);
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
vi.mocked(hasOpenCredentials).mockReturnValue(false);
const r = await danmakuService.fetch("u1", "m1");
expect(r.source).toBe("none");
expect(r.message).toContain("OPEN_DANMAKU_APP_ID");
});
it("已有 local-xml 时不扫描侧车", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
const r = await danmakuService.fetch("u1", "m1");
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" });
expect(createWebdav).not.toHaveBeenCalled();
});
});

View File

@ -12,9 +12,26 @@ import {
} from "@app/danmaku"; } from "@app/danmaku";
import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types"; import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types";
import { decryptSecret } from "./secret.js"; import { decryptSecret } from "./secret.js";
import { createWebdav, joinWebdavPath } from "./webdav-client.js"; import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** 同名侧车 XML 全文上限,与 importXml 一致 */
const SIDECAR_MAX_BYTES = 20_000_000;
/** 媒体路径 → 所在目录("" 表示挂载根)+ 去扩展名 basename */
function splitDirBase(path: string): { dir: string; base: string } {
const norm = path.replace(/\\/g, "/");
const idx = norm.lastIndexOf("/");
const dir = idx >= 0 ? norm.slice(0, idx) : "";
const file = idx >= 0 ? norm.slice(idx + 1) : norm;
const dot = file.lastIndexOf(".");
return { dir, base: dot > 0 ? file.slice(0, dot) : file };
}
/** 同 basename + .xml,扩展名与 stem 均大小写不敏感 */
function isSidecarName(fileName: string, base: string): boolean {
return fileName.toLowerCase() === `${base}.xml`.toLowerCase();
}
function matchKeyFor(path: string, size: number): string { function matchKeyFor(path: string, size: number): string {
return createHash("sha256").update(`${path}:${size}`).digest("hex"); return createHash("sha256").update(`${path}:${size}`).digest("hex");
@ -70,6 +87,46 @@ async function persistOpenCache(episodeId: number, xml: string): Promise<void> {
} }
export const danmakuService = { export const danmakuService = {
/** 同目录同名 .xml 侧车 → 落 local-xml;失败/超限/非弹幕一律静默 false */
async ensureSidecarXml(userId: string, mediaItemId: string): Promise<boolean> {
try {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item?.mountId) return false;
const mount = await mountDao.getByIdForUser(item.mountId, userId);
if (!mount) return false;
const { dir, base } = splitDirBase(item.path);
if (!base) return false;
const client = createWebdav({
baseUrl: mount.baseUrl,
username: mount.username ?? undefined,
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
});
const listAbs = joinWebdavPath(mount.rootPath || "/", dir || "/");
const entries = await listDirectory(client, listAbs);
const hit = entries.find(
(e: DirEntry) => e.type === "file" && isSidecarName(e.basename, base),
);
if (!hit || hit.size > SIDECAR_MAX_BYTES) return false;
const raw = (await client.getFileContents(hit.filename, {
format: "binary",
})) as Uint8Array;
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buf.byteLength > SIDECAR_MAX_BYTES) return false;
const xml = buf.toString("utf8");
if (!xml.includes("<d ")) return false;
await danmakuDocsDao.upsert({
userId,
mediaItemId,
source: "local-xml",
xml,
byteSize: Buffer.byteLength(xml, "utf8"),
});
return true;
} catch {
return false;
}
},
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> { async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
@ -77,6 +134,17 @@ export const danmakuService = {
const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml"); const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml");
if (local) return { ok: true, source: "local-xml", xml: local.xml }; if (local) return { ok: true, source: "local-xml", xml: local.xml };
// 同目录同名 .xml 侧车自动关联;命中即按 local-xml 返回
if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) {
const linked = await danmakuDocsDao.get(userId, mediaItemId, "local-xml");
return {
ok: true,
source: "local-xml",
xml: linked?.xml ?? "",
message: "同名弹幕",
};
}
if (!hasOpenCredentials()) { if (!hasOpenCredentials()) {
return { return {
ok: true, ok: true,