feat(danmaku): auto-link same-name sidecar xml from mount
This commit is contained in:
parent
5b30f43252
commit
b90d7dfc55
|
|
@ -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">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<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 && (
|
||||
<Button size="sm" variant="outline" onClick={openRematch}>
|
||||
{t(locale, "watch.rematch")}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export const en: Record<MessageKey, string> = {
|
|||
"watch.matchManual": "Manual match",
|
||||
"watch.importSaved": "Danmaku saved",
|
||||
"watch.foundSameName": "Same-name candidate",
|
||||
"watch.sidecarLinked": "Same-name danmaku",
|
||||
"watch.filterTitle": "Danmaku filters",
|
||||
"watch.blockKeywordAdd": "Add block word",
|
||||
"watch.matchFailed": "Danmaku match failed",
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export const zhCN = {
|
|||
"watch.matchManual": "手动匹配",
|
||||
"watch.importSaved": "弹幕已保存",
|
||||
"watch.foundSameName": "同名候选",
|
||||
"watch.sidecarLinked": "同名弹幕",
|
||||
"watch.filterTitle": "弹幕过滤",
|
||||
"watch.blockKeywordAdd": "添加屏蔽词",
|
||||
"watch.matchFailed": "弹幕匹配失败",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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 {
|
||||
DanmakuCacheRow,
|
||||
DanmakuDocRow,
|
||||
DanmakuPrefsRow,
|
||||
MediaItem as MediaItemRow,
|
||||
Mount,
|
||||
} from "@app/models";
|
||||
import { danmakuService } from "./danmaku.service.js";
|
||||
import { createWebdav } from "./webdav-client.js";
|
||||
|
||||
vi.mock("@app/dao", () => ({
|
||||
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
|
||||
|
|
@ -20,6 +22,14 @@ vi.mock("@app/dao", () => ({
|
|||
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) => {
|
||||
const actual = await importOriginal<typeof import("@app/danmaku")>();
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
|
@ -364,3 +417,213 @@ describe("openCommentsToXml(@app/danmaku,p 序列化 8 段)", () => {
|
|||
).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,26 @@ import {
|
|||
} from "@app/danmaku";
|
||||
import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types";
|
||||
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;
|
||||
/** 同名侧车 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 {
|
||||
return createHash("sha256").update(`${path}:${size}`).digest("hex");
|
||||
|
|
@ -70,6 +87,46 @@ async function persistOpenCache(episodeId: number, xml: string): Promise<void> {
|
|||
}
|
||||
|
||||
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> {
|
||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||
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");
|
||||
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()) {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue