feat(trpc): danmaku service uses @app/danmaku and persists docs/prefs
This commit is contained in:
parent
aab636eac7
commit
188cf79270
|
|
@ -23,6 +23,7 @@
|
||||||
"dev": "tsx watch src/server.ts"
|
"dev": "tsx watch src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@app/danmaku": "workspace:*",
|
||||||
"@app/dao": "workspace:*",
|
"@app/dao": "workspace:*",
|
||||||
"@app/db": "workspace:*",
|
"@app/db": "workspace:*",
|
||||||
"@app/models": "workspace:*",
|
"@app/models": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
scrapeSchemas,
|
scrapeSchemas,
|
||||||
} from "@app/types";
|
} from "@app/types";
|
||||||
import { t } from "../context.js";
|
import { t } from "../context.js";
|
||||||
import { danmakuService, hasOpenCredentials } from "../services/danmaku.service.js";
|
import { danmakuService } from "../services/danmaku.service.js";
|
||||||
import { libraryService } from "../services/library.service.js";
|
import { libraryService } from "../services/library.service.js";
|
||||||
import { mountService } from "../services/mount.service.js";
|
import { mountService } from "../services/mount.service.js";
|
||||||
import { playbackService } from "../services/playback.service.js";
|
import { playbackService } from "../services/playback.service.js";
|
||||||
|
|
@ -98,18 +98,25 @@ export const mediaRouter = t.router({
|
||||||
danmakuFetch: protectedProcedure
|
danmakuFetch: protectedProcedure
|
||||||
.input(danmakuSchemas.fetch)
|
.input(danmakuSchemas.fetch)
|
||||||
.query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)),
|
.query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)),
|
||||||
danmakuImportMeta: protectedProcedure
|
danmakuMatch: protectedProcedure
|
||||||
.input(danmakuSchemas.importMeta)
|
.input(danmakuSchemas.match)
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
danmakuService.importMeta(ctx.userId, input.mediaItemId, input.byteSize),
|
danmakuService.matchCandidates(ctx.userId, input.mediaItemId),
|
||||||
),
|
),
|
||||||
danmakuGetSettings: protectedProcedure.input(danmakuSchemas.getSettings).query(() => ({
|
danmakuSelectMatch: protectedProcedure
|
||||||
enabled: true,
|
.input(danmakuSchemas.selectMatch)
|
||||||
opacity: 0.8,
|
.mutation(({ ctx, input }) =>
|
||||||
density: 1,
|
danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId),
|
||||||
openNetworkConfigured: hasOpenCredentials(),
|
),
|
||||||
})),
|
danmakuImport: protectedProcedure
|
||||||
|
.input(danmakuSchemas.import)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
danmakuService.importXml(ctx.userId, input.mediaItemId, input.xml),
|
||||||
|
),
|
||||||
|
danmakuGetSettings: protectedProcedure
|
||||||
|
.input(danmakuSchemas.getSettings)
|
||||||
|
.query(({ ctx }) => danmakuService.getPrefs(ctx.userId)),
|
||||||
danmakuSaveSettings: protectedProcedure
|
danmakuSaveSettings: protectedProcedure
|
||||||
.input(danmakuSchemas.settings)
|
.input(danmakuSchemas.settings)
|
||||||
.mutation(({ input }) => input),
|
.mutation(({ ctx, input }) => danmakuService.savePrefs(ctx.userId, input)),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,334 @@
|
||||||
import { createHash } from "node:crypto";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { describe, expect, it } from "vitest";
|
import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku";
|
||||||
import {
|
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao } from "@app/dao";
|
||||||
generateOpenSignature,
|
import type {
|
||||||
headMd5,
|
DanmakuCacheRow,
|
||||||
matchFileName,
|
DanmakuDocRow,
|
||||||
openCommentsToXml,
|
DanmakuPrefsRow,
|
||||||
parseMatchOutcome,
|
MediaItem as MediaItemRow,
|
||||||
} from "./danmaku.service.js";
|
} from "@app/models";
|
||||||
|
import { danmakuService } from "./danmaku.service.js";
|
||||||
|
|
||||||
describe("danmaku open network helpers", () => {
|
vi.mock("@app/dao", () => ({
|
||||||
it("headMd5 matches md5 of sample", () => {
|
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
|
||||||
const sample = Buffer.from("hello");
|
danmakuDocsDao: { get: vi.fn(), upsert: vi.fn() },
|
||||||
expect(headMd5(sample)).toBe(createHash("md5").update(sample).digest("hex"));
|
danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() },
|
||||||
|
mediaItemDao: {
|
||||||
|
getByIdForUser: vi.fn(),
|
||||||
|
setDanmakuMatch: vi.fn(),
|
||||||
|
},
|
||||||
|
mountDao: { getByIdForUser: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/danmaku", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@app/danmaku")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
openMatch: vi.fn(),
|
||||||
|
openCommentXml: vi.fn(),
|
||||||
|
hasOpenCredentials: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function mediaItem(over: Partial<MediaItemRow> = {}): MediaItemRow {
|
||||||
|
return {
|
||||||
|
id: "m1",
|
||||||
|
userId: "u1",
|
||||||
|
mountId: null,
|
||||||
|
path: "/anime/[Sub] Title - 01.mkv",
|
||||||
|
rawName: "[Sub] Title - 01.mkv",
|
||||||
|
title: "Title",
|
||||||
|
size: 1024,
|
||||||
|
mime: null,
|
||||||
|
bangumiId: null,
|
||||||
|
epNumber: 1,
|
||||||
|
scrapeStatus: "pending",
|
||||||
|
scrapedAt: null,
|
||||||
|
posterUrl: null,
|
||||||
|
dandanplayEpisodeId: null,
|
||||||
|
matchedHash: null,
|
||||||
|
danmakuMatchSource: "none",
|
||||||
|
scannedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docRow(over: Partial<DanmakuDocRow> = {}): DanmakuDocRow {
|
||||||
|
return {
|
||||||
|
id: "d1",
|
||||||
|
userId: "u1",
|
||||||
|
mediaItemId: "m1",
|
||||||
|
source: "local-xml",
|
||||||
|
xml: "<i>local</i>",
|
||||||
|
byteSize: 12,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheRow(over: Partial<DanmakuCacheRow> = {}): DanmakuCacheRow {
|
||||||
|
return {
|
||||||
|
id: "c1",
|
||||||
|
matchKey: "4242",
|
||||||
|
payload: "<i>cached</i>",
|
||||||
|
source: "open-network",
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefsRow(over: Partial<DanmakuPrefsRow> = {}): DanmakuPrefsRow {
|
||||||
|
return {
|
||||||
|
id: "p1",
|
||||||
|
userId: "u1",
|
||||||
|
enabled: true,
|
||||||
|
opacity: 80,
|
||||||
|
density: 100,
|
||||||
|
blockKeywords: "[]",
|
||||||
|
blockTypes: "[]",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.importXml", () => {
|
||||||
|
it("校验媒体存在后落 danmakuDocs(source=local-xml),count 粗算 <d 个数", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
const xml =
|
||||||
|
'<?xml version="1.0"?><i><d p="1,1,0,0,0,0,,">a</d><d p="2,1,0,0,0,0,,">b</d></i>';
|
||||||
|
const r = await danmakuService.importXml("u1", "m1", xml);
|
||||||
|
expect(r).toEqual({ ok: true, count: 2 });
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
mediaItemId: "m1",
|
||||||
|
source: "local-xml",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("signature is base64(sha256(AppId + Timestamp + Path + AppSecret))", () => {
|
it("媒体不存在抛 NOT_FOUND", async () => {
|
||||||
const sig = generateOpenSignature("app", 1735660800, "/api/v2/comment/1", "sec");
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
const expectSig = createHash("sha256")
|
await expect(danmakuService.importXml("u1", "gone", "<i><d /></i>")).rejects.toMatchObject({
|
||||||
.update("app" + "1735660800" + "/api/v2/comment/1" + "sec")
|
code: "NOT_FOUND",
|
||||||
.digest("base64");
|
});
|
||||||
expect(sig).toBe(expectSig);
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("comments p/m maps to bilibili xml", () => {
|
it("无 <d 节点抛 BAD_REQUEST", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
await expect(danmakuService.importXml("u1", "m1", "<html></html>")).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.fetch", () => {
|
||||||
|
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(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||||
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体不存在返回 ok:false", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.fetch("u1", "gone");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已绑 episodeId 且缓存有效 → source:cache", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
|
||||||
|
mediaItem({ dandanplayEpisodeId: 77 }),
|
||||||
|
);
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(
|
||||||
|
cacheRow({ payload: "<i>cached</i>" }),
|
||||||
|
);
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "cache", xml: "<i>cached</i>" });
|
||||||
|
expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77");
|
||||||
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未配置开放凭据时明示缺配置", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
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");
|
||||||
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("高置信自动绑(source=auto)后拉取,缓存键为 String(episodeId)", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
episodeId: 555,
|
||||||
|
animeId: 1,
|
||||||
|
animeTitle: "T",
|
||||||
|
episodeTitle: "E",
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
|
episodeId: 555,
|
||||||
|
matchedHash: null,
|
||||||
|
source: "auto",
|
||||||
|
});
|
||||||
|
expect(openCommentXml).toHaveBeenCalledWith(555);
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: "open-network", xml: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
expect(danmakuCacheDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
matchKey: "555",
|
||||||
|
payload: "<i>open</i>",
|
||||||
|
source: "open-network",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.selectMatch", () => {
|
||||||
|
it("写 manual 源绑定后拉取,缓存键 String(episodeId)", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
|
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
||||||
|
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
|
episodeId: 4242,
|
||||||
|
matchedHash: "abc",
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
expect(danmakuCacheDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ matchKey: "4242", payload: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: "open-network", xml: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拉取失败仍保留 manual 绑定,返回 none", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
expect(r.message).toContain("拉取失败");
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled();
|
||||||
|
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.matchCandidates", () => {
|
||||||
|
it("透传开放网络候选与 highConfidence", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{ episodeId: 9, animeId: 2, animeTitle: "A", episodeTitle: "E1", imageUrl: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await danmakuService.matchCandidates("u1", "m1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.highConfidence).toBe(true);
|
||||||
|
expect(r.candidates[0]?.episodeId).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体不存在 → matched:false", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.matchCandidates("u1", "gone");
|
||||||
|
expect(r).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService prefs", () => {
|
||||||
|
it("getPrefs 默认值 + 整数百分比转 0–1 小数", async () => {
|
||||||
|
vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
const r = await danmakuService.getPrefs("u1");
|
||||||
|
expect(r).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
density: 1,
|
||||||
|
blockKeywords: [],
|
||||||
|
blockTypes: [],
|
||||||
|
openNetworkConfigured: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savePrefs 0–1 小数写回整数百分比", async () => {
|
||||||
|
vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue(
|
||||||
|
prefsRow({ opacity: 75, density: 50, blockKeywords: '["x"]', blockTypes: '["top"]' }),
|
||||||
|
);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
|
const r = await danmakuService.savePrefs("u1", {
|
||||||
|
enabled: false,
|
||||||
|
opacity: 0.75,
|
||||||
|
density: 0.5,
|
||||||
|
blockKeywords: ["x"],
|
||||||
|
blockTypes: ["top"],
|
||||||
|
});
|
||||||
|
expect(danmakuPrefsDao.upsert).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
enabled: false,
|
||||||
|
opacity: 75,
|
||||||
|
density: 50,
|
||||||
|
blockKeywords: '["x"]',
|
||||||
|
blockTypes: '["top"]',
|
||||||
|
});
|
||||||
|
expect(r.opacity).toBe(0.75);
|
||||||
|
expect(r.density).toBe(0.5);
|
||||||
|
expect(r.blockTypes).toEqual(["top"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openCommentsToXml(@app/danmaku,p 序列化 8 段)", () => {
|
||||||
|
it("comments p/m maps to bilibili xml(8 段 p)", () => {
|
||||||
const xml = openCommentsToXml([
|
const xml = openCommentsToXml([
|
||||||
{ p: "12.5,1,16777215", m: "hello" },
|
{ p: "12.5,1,16777215", m: "hello" },
|
||||||
{ p: "3,5,16777215", m: "top & <b>" },
|
{ p: "3,5,16777215", m: "top & <b>" },
|
||||||
]);
|
]);
|
||||||
expect(xml).toContain('<d p="12.5,1,0,0">hello</d>');
|
expect(xml).toContain('<d p="12.5,1,16777215,0,0,0,,">hello</d>');
|
||||||
expect(xml).toContain('<d p="3,5,0,0">top & <b></d>');
|
expect(xml).toContain('<d p="3,5,16777215,0,0,0,,">top & <b></d>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("comments with explicit fields also work", () => {
|
it("comments with explicit fields also work", () => {
|
||||||
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
|
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
|
||||||
expect(xml).toContain('<d p="1.25,4,0,0">x</d>');
|
expect(xml).toContain('<d p="1.25,4,0,0,0,0,,">x</d>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips invalid comments", () => {
|
it("skips invalid comments", () => {
|
||||||
|
|
@ -45,54 +340,3 @@ describe("danmaku open network helpers", () => {
|
||||||
).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>');
|
).toBe('<?xml version="1.0" encoding="UTF-8"?><i></i>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("matchFileName", () => {
|
|
||||||
it("去文件夹路径与扩展名(官方 match 要求)", () => {
|
|
||||||
expect(matchFileName("/anime/[Sub] Title - 01 [1080p].mkv")).toBe(
|
|
||||||
"[Sub] Title - 01 [1080p]",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it("点开头的隐藏文件不算扩展名", () => {
|
|
||||||
expect(matchFileName(".hidden")).toBe(".hidden");
|
|
||||||
});
|
|
||||||
it("无扩展名原样保留", () => {
|
|
||||||
expect(matchFileName("Movie")).toBe("Movie");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMatchOutcome", () => {
|
|
||||||
it("提取候选及其刮削元数据", () => {
|
|
||||||
const r = parseMatchOutcome({
|
|
||||||
isMatched: true,
|
|
||||||
matches: [
|
|
||||||
{
|
|
||||||
episodeId: 111,
|
|
||||||
animeId: 9,
|
|
||||||
animeTitle: "葬送的芙莉莲",
|
|
||||||
episodeTitle: "第1话 冒险终结之地",
|
|
||||||
imageUrl: "http://img/1.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r.isMatched).toBe(true);
|
|
||||||
expect(r.candidates[0]?.episodeId).toBe(111);
|
|
||||||
expect(r.candidates[0]?.animeId).toBe(9);
|
|
||||||
expect(r.candidates[0]?.animeTitle).toBe("葬送的芙莉莲");
|
|
||||||
expect(r.candidates[0]?.imageUrl).toBe("http://img/1.jpg");
|
|
||||||
});
|
|
||||||
it("episodeId 缺失的条目丢弃,字符串 id 可解析", () => {
|
|
||||||
const r = parseMatchOutcome({
|
|
||||||
isMatched: false,
|
|
||||||
matches: [{ episodeId: "222" }, { animeTitle: "no id" }],
|
|
||||||
});
|
|
||||||
expect(r.isMatched).toBe(false);
|
|
||||||
expect(r.candidates).toHaveLength(1);
|
|
||||||
expect(r.candidates[0]?.episodeId).toBe(222);
|
|
||||||
expect(r.candidates[0]?.animeTitle).toBeNull();
|
|
||||||
});
|
|
||||||
it("空响应安全兜底", () => {
|
|
||||||
const r = parseMatchOutcome({});
|
|
||||||
expect(r.isMatched).toBe(false);
|
|
||||||
expect(r.candidates).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,228 +1,20 @@
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
|
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
|
||||||
import type { DanmakuFetchOutput } from "@app/types";
|
import {
|
||||||
|
HASH_HEAD_BYTES,
|
||||||
|
hasOpenCredentials,
|
||||||
|
headMd5,
|
||||||
|
matchFileName,
|
||||||
|
openCommentXml,
|
||||||
|
openMatch,
|
||||||
|
rankCandidates,
|
||||||
|
} from "@app/danmaku";
|
||||||
|
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 } from "./webdav-client.js";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
/** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */
|
|
||||||
const HASH_HEAD_BYTES = 16 * 1024 * 1024;
|
|
||||||
|
|
||||||
type OpenComment = {
|
|
||||||
p?: string;
|
|
||||||
m?: string;
|
|
||||||
text?: string;
|
|
||||||
mode?: number;
|
|
||||||
time?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** MatchResultV2 刮削子集(swagger:episodeId/animeId/animeTitle/episodeTitle/imageUrl) */
|
|
||||||
export type OpenMatchCandidate = {
|
|
||||||
episodeId: number;
|
|
||||||
animeId: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
episodeTitle: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OpenMatchOutcome = {
|
|
||||||
ok: boolean;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isMatched: boolean;
|
|
||||||
candidates: OpenMatchCandidate[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function openApiBase(): string {
|
|
||||||
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
|
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCredentials(): { appId: string; appSecret: string } | null {
|
|
||||||
const appId = process.env["OPEN_DANMAKU_APP_ID"];
|
|
||||||
const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"];
|
|
||||||
if (!appId || !appSecret) return null;
|
|
||||||
return { appId, appSecret };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 凭据是否已配置;未配置时开放网络整体不可用,UI 需明示而非静默降级 */
|
|
||||||
export function hasOpenCredentials(): boolean {
|
|
||||||
return openCredentials() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */
|
|
||||||
export function generateOpenSignature(
|
|
||||||
appId: string,
|
|
||||||
timestamp: number,
|
|
||||||
path: string,
|
|
||||||
appSecret: string,
|
|
||||||
): string {
|
|
||||||
return createHash("sha256").update(`${appId}${timestamp}${path}${appSecret}`).digest("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文件头部 MD5(样本应已截到 16MB 内) */
|
|
||||||
export function headMd5(sample: Buffer): string {
|
|
||||||
return createHash("md5").update(sample).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 官方 match 要求 fileName 不含文件夹路径与扩展名;点开头的隐藏文件不算扩展名 */
|
|
||||||
export function matchFileName(path: string): string {
|
|
||||||
const base = path.split("/").pop() ?? path;
|
|
||||||
const dot = base.lastIndexOf(".");
|
|
||||||
return dot > 0 ? base.slice(0, dot) : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,0,0">text</d>`) */
|
|
||||||
export function openCommentsToXml(comments: OpenComment[]): string {
|
|
||||||
const nodes: string[] = [];
|
|
||||||
for (const c of comments) {
|
|
||||||
let time = Number.NaN;
|
|
||||||
let mode = 1;
|
|
||||||
let text = "";
|
|
||||||
if (typeof c.p === "string") {
|
|
||||||
const [t, m] = c.p.split(",");
|
|
||||||
time = Number(t);
|
|
||||||
mode = Number(m) || 1;
|
|
||||||
text = c.m ?? "";
|
|
||||||
} else {
|
|
||||||
time = Number(c.time);
|
|
||||||
mode = Number(c.mode) || 1;
|
|
||||||
text = c.text ?? c.m ?? "";
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(time) || !text) continue;
|
|
||||||
nodes.push(`<d p="${time},${mode},0,0">${escapeXml(text)}</d>`);
|
|
||||||
}
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHeaders(path: string): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const creds = openCredentials();
|
|
||||||
if (!creds) return headers;
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
...headers,
|
|
||||||
"X-AppId": creds.appId,
|
|
||||||
"X-Timestamp": String(timestamp),
|
|
||||||
"X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenApiBase = {
|
|
||||||
errorCode?: number;
|
|
||||||
success?: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
errorDetail?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 开放网络统一请求:拼 base + 鉴权头;HTTP 非 2xx / success=false / errorCode≠0 / 网络错误统一归到 ok:false */
|
|
||||||
export async function openFetchJson(
|
|
||||||
path: string,
|
|
||||||
init?: { method?: "POST"; body?: string } | undefined,
|
|
||||||
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${openApiBase()}${path}`, {
|
|
||||||
method: init?.method ?? "GET",
|
|
||||||
headers: openHeaders(path),
|
|
||||||
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
const json: unknown = await res.json();
|
|
||||||
const base = json as OpenApiBase;
|
|
||||||
if (base.success === false || (base.errorCode ?? 0) !== 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: res.status,
|
|
||||||
error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, json };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: null,
|
|
||||||
error: e instanceof Error ? e.message : "弹弹play API 网络错误",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawMatch = {
|
|
||||||
episodeId?: number | string;
|
|
||||||
animeId?: number | string;
|
|
||||||
animeTitle?: string | null;
|
|
||||||
episodeTitle?: string | null;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 解析 /api/v2/match 响应体为候选列表(纯函数便于单测);无 episodeId 的条目丢弃 */
|
|
||||||
export function parseMatchOutcome(json: unknown): {
|
|
||||||
isMatched: boolean;
|
|
||||||
candidates: OpenMatchCandidate[];
|
|
||||||
} {
|
|
||||||
const body = json as { isMatched?: boolean; matches?: RawMatch[] };
|
|
||||||
const candidates: OpenMatchCandidate[] = [];
|
|
||||||
for (const m of body.matches ?? []) {
|
|
||||||
const episodeId =
|
|
||||||
typeof m.episodeId === "number"
|
|
||||||
? m.episodeId
|
|
||||||
: Number.parseInt(String(m.episodeId ?? ""), 10);
|
|
||||||
if (!Number.isFinite(episodeId)) continue;
|
|
||||||
const animeId =
|
|
||||||
typeof m.animeId === "number"
|
|
||||||
? m.animeId
|
|
||||||
: Number.parseInt(String(m.animeId ?? ""), 10);
|
|
||||||
candidates.push({
|
|
||||||
episodeId,
|
|
||||||
animeId: Number.isFinite(animeId) ? animeId : null,
|
|
||||||
animeTitle: m.animeTitle ?? null,
|
|
||||||
episodeTitle: m.episodeTitle ?? null,
|
|
||||||
imageUrl: m.imageUrl ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { isMatched: body.isMatched === true, candidates };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v2/match;错误透出 errorMessage,不再吞成 null */
|
|
||||||
export async function openMatch(
|
|
||||||
fileName: string,
|
|
||||||
fileSize: number,
|
|
||||||
fileHash: string | null,
|
|
||||||
): Promise<OpenMatchOutcome> {
|
|
||||||
const res = await openFetchJson("/api/v2/match", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName,
|
|
||||||
fileHash: fileHash ?? "",
|
|
||||||
fileSize,
|
|
||||||
matchMode: fileHash ? "hashAndFileName" : "fileNameOnly",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] };
|
|
||||||
}
|
|
||||||
const parsed = parseMatchOutcome(res.json);
|
|
||||||
return { ok: true, errorMessage: null, ...parsed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/v2/comment/{episodeId}?withRelated=true → Bilibili 式 XML */
|
|
||||||
export async function openCommentXml(episodeId: number): Promise<string | null> {
|
|
||||||
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const body = res.json as { comments?: OpenComment[] };
|
|
||||||
return openCommentsToXml(body.comments ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
||||||
|
|
@ -258,12 +50,34 @@ export async function remoteFileHash(userId: string, mediaId: string): Promise<s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistOpenXml(userId: string, mediaItemId: string, xml: string): Promise<void> {
|
||||||
|
await danmakuDocsDao.upsert({
|
||||||
|
userId,
|
||||||
|
mediaItemId,
|
||||||
|
source: "open-network",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistOpenCache(episodeId: number, xml: string): Promise<void> {
|
||||||
|
await danmakuCacheDao.upsert({
|
||||||
|
matchKey: String(episodeId),
|
||||||
|
payload: xml,
|
||||||
|
source: "open-network",
|
||||||
|
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const danmakuService = {
|
export const danmakuService = {
|
||||||
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: "媒体不存在" };
|
||||||
|
|
||||||
if (!openCredentials()) {
|
const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml");
|
||||||
|
if (local) return { ok: true, source: "local-xml", xml: local.xml };
|
||||||
|
|
||||||
|
if (!hasOpenCredentials()) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
|
|
@ -273,18 +87,19 @@ export const danmakuService = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchKey = matchKeyFor(item.path, item.size);
|
const cacheKey =
|
||||||
const cached = await danmakuCacheDao.getValid(matchKey);
|
item.dandanplayEpisodeId != null
|
||||||
if (cached) {
|
? String(item.dandanplayEpisodeId)
|
||||||
return { ok: true, source: "cache", xml: cached.payload };
|
: matchKeyFor(item.path, item.size);
|
||||||
}
|
const cached = await danmakuCacheDao.getValid(cacheKey);
|
||||||
|
if (cached) return { ok: true, source: "cache", xml: cached.payload };
|
||||||
|
|
||||||
let episodeId = item.dandanplayEpisodeId ?? null;
|
let episodeId = item.dandanplayEpisodeId ?? null;
|
||||||
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 best = outcome.candidates[0];
|
const ranked = rankCandidates(outcome.candidates);
|
||||||
if (!outcome.ok || !outcome.isMatched || !best) {
|
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
|
|
@ -292,11 +107,23 @@ export const danmakuService = {
|
||||||
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
episodeId = best.episodeId;
|
// 仅高置信才自动绑;否则等手选(fetch 不落库)
|
||||||
// 识别身份落库:后续播放直接 comment,免重跑 16MB 读流 + match(元数据覆盖走库页识别入口)
|
if (!ranked.highConfidence) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: "none",
|
||||||
|
xml: "",
|
||||||
|
message: "匹配不明确,请在播放页手选弹幕集",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
||||||
|
if (episodeId == null) {
|
||||||
|
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||||||
|
}
|
||||||
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
episodeId,
|
episodeId,
|
||||||
matchedHash: hash,
|
matchedHash: hash,
|
||||||
|
source: "auto",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,22 +131,119 @@ export const danmakuService = {
|
||||||
if (xml === null) {
|
if (xml === null) {
|
||||||
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||||
}
|
}
|
||||||
await danmakuCacheDao.upsert({
|
await persistOpenXml(userId, mediaItemId, xml);
|
||||||
matchKey,
|
await persistOpenCache(episodeId, xml);
|
||||||
payload: xml,
|
|
||||||
source: "open-network",
|
|
||||||
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
|
||||||
});
|
|
||||||
return { ok: true, source: "open-network", xml };
|
return { ok: true, source: "open-network", xml };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
|
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
|
||||||
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
message: "媒体不存在",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasOpenCredentials()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
message: "未配置开放弹幕网络",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hash = await remoteFileHash(userId, mediaItemId);
|
||||||
|
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
||||||
|
const ranked = rankCandidates(outcome.candidates);
|
||||||
|
return {
|
||||||
|
ok: outcome.ok,
|
||||||
|
matched: outcome.isMatched && ranked.candidates.length > 0,
|
||||||
|
message: outcome.errorMessage ?? undefined,
|
||||||
|
candidates: ranked.candidates,
|
||||||
|
highConfidence: ranked.highConfidence,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectMatch(
|
||||||
|
userId: string,
|
||||||
|
mediaItemId: string,
|
||||||
|
episodeId: number,
|
||||||
|
): Promise<DanmakuFetchOutput> {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||||
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
|
episodeId,
|
||||||
|
matchedHash: item.matchedHash,
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
const xml = await openCommentXml(episodeId);
|
||||||
|
if (xml === null) {
|
||||||
|
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||||
|
}
|
||||||
|
await persistOpenXml(userId, mediaItemId, xml);
|
||||||
|
await persistOpenCache(episodeId, xml);
|
||||||
|
return { ok: true, source: "open-network", xml };
|
||||||
|
},
|
||||||
|
|
||||||
|
async importXml(
|
||||||
|
userId: string,
|
||||||
|
mediaItemId: string,
|
||||||
|
xml: string,
|
||||||
|
): 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 (byteSize > 20_000_000) {
|
if (xml.length > 20_000_000) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
|
||||||
}
|
}
|
||||||
return { ok: true };
|
if (!xml.includes("<d ")) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "不是有效的弹幕 XML" });
|
||||||
|
}
|
||||||
|
await danmakuDocsDao.upsert({
|
||||||
|
userId,
|
||||||
|
mediaItemId,
|
||||||
|
source: "local-xml",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
return { ok: true, count: (xml.match(/<d /g) ?? []).length };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPrefs(userId: string): Promise<DanmakuSettingsOutput> {
|
||||||
|
const row = await danmakuPrefsDao.getByUser(userId);
|
||||||
|
return {
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
opacity: (row?.opacity ?? 80) / 100,
|
||||||
|
density: (row?.density ?? 100) / 100,
|
||||||
|
blockKeywords: row ? (JSON.parse(row.blockKeywords) as string[]) : [],
|
||||||
|
blockTypes: row
|
||||||
|
? (JSON.parse(row.blockTypes) as Array<"scroll" | "top" | "bottom">)
|
||||||
|
: [],
|
||||||
|
openNetworkConfigured: hasOpenCredentials(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePrefs(
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
enabled: boolean;
|
||||||
|
opacity: number;
|
||||||
|
density: number;
|
||||||
|
blockKeywords: string[];
|
||||||
|
blockTypes: Array<"scroll" | "top" | "bottom">;
|
||||||
|
},
|
||||||
|
): Promise<DanmakuSettingsOutput> {
|
||||||
|
await danmakuPrefsDao.upsert({
|
||||||
|
userId,
|
||||||
|
enabled: input.enabled,
|
||||||
|
opacity: Math.round(input.opacity * 100),
|
||||||
|
density: Math.round(input.density * 100),
|
||||||
|
blockKeywords: JSON.stringify(input.blockKeywords),
|
||||||
|
blockTypes: JSON.stringify(input.blockTypes),
|
||||||
|
});
|
||||||
|
return danmakuService.getPrefs(userId);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { mediaItemDao } from "@app/dao";
|
import { mediaItemDao } from "@app/dao";
|
||||||
import type { BatchResult, DanmakuMatchOutput } from "@app/types";
|
|
||||||
import {
|
import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
matchFileName,
|
matchFileName,
|
||||||
openFetchJson,
|
openFetchJson,
|
||||||
openMatch,
|
openMatch,
|
||||||
remoteFileHash,
|
rankCandidates,
|
||||||
} from "./danmaku.service.js";
|
} from "@app/danmaku";
|
||||||
|
import type { BatchResult, DanmakuMatchOutput } from "@app/types";
|
||||||
|
import { remoteFileHash } from "./danmaku.service.js";
|
||||||
|
|
||||||
/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */
|
/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */
|
||||||
export function parseEpisodeFromFilename(name: string): number | null {
|
export function parseEpisodeFromFilename(name: string): number | null {
|
||||||
|
|
@ -216,6 +217,8 @@ export const scrapeService = {
|
||||||
matched: false,
|
matched: false,
|
||||||
message:
|
message:
|
||||||
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
|
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,8 +229,11 @@ export const scrapeService = {
|
||||||
ok: false,
|
ok: false,
|
||||||
matched: false,
|
matched: false,
|
||||||
message: outcome.errorMessage ?? "弹弹play 识别失败",
|
message: outcome.errorMessage ?? "弹弹play 识别失败",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const ranked = rankCandidates(outcome.candidates);
|
||||||
const best = outcome.candidates[0];
|
const best = outcome.candidates[0];
|
||||||
if (best) {
|
if (best) {
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
|
|
@ -242,13 +248,8 @@ export const scrapeService = {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
result: {
|
candidates: ranked.candidates,
|
||||||
episodeId: best.episodeId,
|
highConfidence: ranked.highConfidence,
|
||||||
animeId: best.animeId,
|
|
||||||
animeTitle: best.animeTitle,
|
|
||||||
episodeTitle: best.episodeTitle,
|
|
||||||
imageUrl: best.imageUrl,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,17 +269,26 @@ export const scrapeService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
|
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
|
||||||
result: {
|
candidates: [
|
||||||
episodeId: bridged.episodeId,
|
{
|
||||||
animeId: null,
|
episodeId: bridged.episodeId,
|
||||||
animeTitle: row.title,
|
animeId: null,
|
||||||
episodeTitle: bridged.episodeTitle,
|
animeTitle: row.title,
|
||||||
imageUrl: row.posterUrl,
|
episodeTitle: bridged.episodeTitle,
|
||||||
},
|
imageUrl: row.posterUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
highConfidence: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, matched: false, message: "弹弹play 未识别到该文件" };
|
return {
|
||||||
|
ok: true,
|
||||||
|
matched: false,
|
||||||
|
message: "弹弹play 未识别到该文件",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */
|
/** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@app/danmaku": ["../danmaku/src/index.ts"],
|
||||||
"@app/types": ["../types/src/index.ts"],
|
"@app/types": ["../types/src/index.ts"],
|
||||||
"@app/dao": ["../dao/src/index.ts"],
|
"@app/dao": ["../dao/src/index.ts"],
|
||||||
"@app/db": ["../db/src/index.ts"],
|
"@app/db": ["../db/src/index.ts"],
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
|
"@app/danmaku",
|
||||||
"@app/dao",
|
"@app/dao",
|
||||||
"@app/db",
|
"@app/db",
|
||||||
"@app/models",
|
"@app/models",
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"@app/danmaku@workspace:packages/danmaku":
|
"@app/danmaku@workspace:*, @app/danmaku@workspace:packages/danmaku":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/danmaku@workspace:packages/danmaku"
|
resolution: "@app/danmaku@workspace:packages/danmaku"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -314,6 +314,7 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/trpc@workspace:packages/trpc"
|
resolution: "@app/trpc@workspace:packages/trpc"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@app/danmaku": "workspace:*"
|
||||||
"@app/dao": "workspace:*"
|
"@app/dao": "workspace:*"
|
||||||
"@app/db": "workspace:*"
|
"@app/db": "workspace:*"
|
||||||
"@app/models": "workspace:*"
|
"@app/models": "workspace:*"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue