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">
|
<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")}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "弹幕匹配失败",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue