Merge branch 'feat/dandanplay-alignment'

# Conflicts:
#	app/web/src/components/pages/WatchBody.tsx
#	packages/trpc/src/services/scrape.service.ts
#	packages/types/src/media-schemas.test.ts
This commit is contained in:
noelorin 2026-09-27 02:18:49 +08:00
commit 5f5dd968cb
46 changed files with 2855 additions and 416 deletions

View File

@ -13,11 +13,12 @@
| 包 | 职责 | 依赖 |
|----|------|------|
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
| `@app/danmaku` | 弹幕开放协议纯函数(签名/hash/XML/匹配/过滤;`./browser` 无 node:crypto) | — |
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao, danmaku |
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, danmaku(`./browser`), design-tokens, seo-geo, i18n, ui |
| `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
| `@app/design-tokens` | 共享 CSS 设计令牌(`tokens.css`) | — |
| `@app/seo-geo` | SEO/GEO 纯函数(meta、sitemap、JSON-D、llms.txt) | zod, schema-dts, escape-html, fast-xml-builder, serialize-javascript, mdast-util-to-markdown |
@ -29,8 +30,10 @@
```
web ──► trpc ──► dao ──► db ──► models
│ │ │
│ │ │ │
│ │ └──► danmaku │
│ └─────────────────┴──► types
├──► danmaku(浏览器侧走 ./browser)
├──► design-tokens
├──► seo-geo
├──► i18n
@ -55,6 +58,7 @@ docs(独立,不依赖上述业务包)
```bash
yarn install
yarn test # 各包 vitest(turbo run test)
yarn typecheck # 全仓 typecheck
yarn lint # Biome
yarn build # 全仓 build(依赖拓扑)

View File

@ -12,6 +12,7 @@
"lint": "biome check ."
},
"dependencies": {
"@app/danmaku": "workspace:*",
"@app/design-tokens": "workspace:*",
"@app/i18n": "workspace:*",
"@app/seo-geo": "workspace:*",

View File

@ -1,67 +1,158 @@
import type { Locale } from "@app/i18n";
import type { Locale, MessageKey } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Card, toast } from "@app/ui";
import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
type BlockType = "scroll" | "top" | "bottom";
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
type DanmakuSettings = {
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string[];
blockTypes: BlockType[];
};
const FALLBACK: DanmakuSettings = {
enabled: true,
opacity: 0.8,
density: 1,
blockKeywords: [],
blockTypes: [],
};
const BLOCK_TYPE_LABELS = [
["scroll", "danmaku.blockTypeScroll"],
["top", "danmaku.blockTypeTop"],
["bottom", "danmaku.blockTypeBottom"],
] as const satisfies ReadonlyArray<readonly [BlockType, MessageKey]>;
function parseBlockTypes(raw: unknown): BlockType[] {
if (!Array.isArray(raw)) return [];
const allowed: BlockType[] = ["scroll", "top", "bottom"];
return raw.filter((v): v is BlockType => allowed.includes(v as BlockType));
}
function readSettings(): DanmakuSettings {
try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return FALLBACK;
if (!raw) return { ...FALLBACK };
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return FALLBACK;
if (typeof parsed !== "object" || parsed === null) return { ...FALLBACK };
const rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density;
const kwRaw = rec["blockKeywords"];
const blockKeywords = Array.isArray(kwRaw)
? kwRaw.filter(
(v): v is string => typeof v === "string" && v.length > 0 && v.length <= 50,
)
: [];
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)),
blockKeywords: blockKeywords.slice(0, 100),
blockTypes: parseBlockTypes(rec["blockTypes"]),
};
} catch {
return FALLBACK;
return { ...FALLBACK };
}
}
function writeSettings(next: DanmakuSettings) {
try {
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
} catch {
// localStorage 不可用时静默;服务端已持久化
}
}
function Inner(props: { locale: Locale }) {
const { locale } = props;
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
const save = trpc.media.danmakuSaveSettings.useMutation();
const filledFromServer = useRef(false);
// SSR 与水合首帧共用 FALLBACK,避免 localStorage 差异导致 hydration 不一致
const [enabled, setEnabled] = useState(FALLBACK.enabled);
const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density);
const [blockKeywords, setBlockKeywords] = useState<string[]>(() => []);
const [blockTypes, setBlockTypes] = useState<BlockType[]>(() => []);
const [kwInput, setKwInput] = useState("");
// 挂载后再读 localStorage 作初值/回退(客户端 only)
useEffect(() => {
const local = readSettings();
if (filledFromServer.current) return;
setEnabled(local.enabled);
setOpacity(local.opacity);
setDensity(local.density);
setBlockKeywords(local.blockKeywords);
setBlockTypes(local.blockTypes);
}, []);
useEffect(() => {
const prefs = readSettings();
setEnabled(prefs.enabled);
setOpacity(prefs.opacity);
setDensity(prefs.density);
const data = prefsQuery.data;
if (!data || filledFromServer.current) return;
filledFromServer.current = true;
setEnabled(data.enabled);
setOpacity(data.opacity);
setDensity(data.density);
setBlockKeywords(data.blockKeywords);
setBlockTypes(data.blockTypes);
}, [prefsQuery.data]);
const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim();
if (!kw || kw.length > 50) return;
setBlockKeywords((prev) => {
if (prev.includes(kw) || prev.length >= 100) return prev;
return [...prev, kw];
});
setKwInput("");
}, [kwInput]);
const removeBlockKeyword = useCallback((kw: string) => {
setBlockKeywords((prev) => prev.filter((k) => k !== kw));
}, []);
const toggleBlockType = useCallback((kind: BlockType) => {
setBlockTypes((prev) =>
prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind],
);
}, []);
const onSave = () => {
const next = { enabled, opacity, density };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes };
writeSettings(next);
save.mutate(next, {
onSuccess: () => {
toast.success(t(locale, "danmaku.saved"));
},
onError: () => {
toast.error(t(locale, "danmaku.save"));
toast.error(t(locale, "danmaku.saveFailed"));
},
});
};
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
{openNetworkMissing && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
{t(locale, "danmaku.openNetworkNotConfigured")}
</div>
)}
<Card className="max-w-md space-y-5 p-6">
<label className="flex items-center gap-2.5 text-sm font-medium">
<input
@ -105,14 +196,67 @@ function Inner(props: { locale: Locale }) {
className="w-full"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockKeywords")}</p>
<div className="flex flex-wrap items-center gap-2">
{blockKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeBlockKeyword(kw)}
>
×
</button>
</span>
))}
<Input
className="h-8 w-40 text-sm"
value={kwInput}
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addBlockKeyword();
}
}}
/>
<Button type="button" size="sm" variant="outline" onClick={addBlockKeyword}>
{t(locale, "watch.blockKeywordAdd")}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockTypes")}</p>
<div className="flex flex-wrap gap-3">
{BLOCK_TYPE_LABELS.map(([kind, labelKey]) => (
<label
key={kind}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={blockTypes.includes(kind)}
onChange={() => toggleBlockType(kind)}
/>
{t(locale, labelKey)}
</label>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button type="button" onClick={onSave}>
<Button type="button" onClick={onSave} disabled={save.isPending}>
{t(locale, "danmaku.save")}
</Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.serverPrefsHint")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.localImportHint")}
</p>

View File

@ -1,12 +1,18 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { parseEpisodeFromFilename } from "@app/types";
import { Button, toast } from "@app/ui";
import { Button, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer";
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render";
import {
filterComments,
parseDanmakuXml,
thinDanmaku,
toDanmukuItems,
type FilterOptions,
} from "~/lib/danmaku-render";
import { go, queryParam } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils";
@ -19,6 +25,7 @@ type DanmukuPlugin = {
load: (danmuku: unknown) => Promise<unknown> | unknown;
show: () => unknown;
hide: () => unknown;
config: (option: { opacity?: number }) => unknown;
};
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
@ -89,6 +96,14 @@ function writeLocalXml(mediaItemId: string, xml: string) {
}
}
function clearLocalXml(mediaItemId: string) {
try {
localStorage.removeItem(DANMAKU_XML_PREFIX + mediaItemId);
} catch {
/* noop */
}
}
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
function sendProgressBeacon(input: ProgressInput) {
const url = "/api/trpc/media.playbackReport?batch=1";
@ -121,14 +136,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const progressStartRef = useRef(0);
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
const [localXml, setLocalXml] = useState("");
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
const [matchXml, setMatchXml] = useState("");
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
const [ended, setEnded] = useState(false);
const [playError, setPlayError] = useState(false);
const [matchOpen, setMatchOpen] = useState(false);
const [kwInput, setKwInput] = useState("");
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
// 进入/切换条目时恢复本地导入的弹幕
useEffect(() => {
setLocalXml(readLocalXml(mediaItemId));
setMatchXml("");
setFilterDraft(null);
setMatchOpen(false);
setPlayError(false);
setEnded(false);
}, [mediaItemId]);
@ -136,8 +159,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
const prefs = trpc.media.danmakuGetSettings.useQuery();
const report = trpc.media.playbackReport.useMutation();
const importMeta = trpc.media.danmakuImportMeta.useMutation();
const importXml = trpc.media.danmakuImport.useMutation();
const matchMut = trpc.media.danmakuMatch.useMutation();
const selectMut = trpc.media.danmakuSelectMatch.useMutation();
const reportRef = useRef(report);
useEffect(() => {
reportRef.current = report;
@ -177,6 +203,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
setDanmakuDensity(prefs.density);
}, []);
// 服务端 prefs 到达后覆盖显示参数;localStorage 仅作 query 失败回退
useEffect(() => {
const data = prefs.data;
if (!data) return;
const next: DanmakuPrefs = {
enabled: data.enabled,
opacity: Math.min(1, Math.max(0, data.opacity)),
density: Math.min(1, Math.max(0.1, data.density)),
};
prefsRef.current = next;
setDanmakuOn(next.enabled);
setDanmakuDensity(next.density);
const art = artRef.current;
const dm = art ? danmukuPlugin(art) : null;
if (dm) dm.config({ opacity: next.opacity });
}, [prefs.data]);
useEffect(() => {
if (progress.data) {
progressStartRef.current = progress.data.positionMs;
@ -327,11 +370,21 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}
}, [progress.data]);
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
const items = useMemo(() => {
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
return thinDanmaku(all, danmakuDensity);
}, [xmlSource, danmakuDensity]);
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
raw.sort((a, b) => a.time - b.time);
const filters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
const blocked = filters ? filterComments(raw, filters) : raw;
return toDanmukuItems(thinDanmaku(blocked, danmakuDensity));
}, [xmlSource, prefs.data, filterDraft, danmakuDensity]);
const danmakuCount = items.length;
// 弹幕数据变化时交给官方插件渲染
@ -358,19 +411,111 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? "");
importXml.mutate(
{ mediaItemId, xml: text, fileName: file.name },
{
onSuccess: () => {
setLocalXml(text);
writeLocalXml(mediaItemId, text);
importMeta.mutate(
{ mediaItemId, byteSize: file.size },
{
onSuccess: () => toast.success(t(locale, "watch.importOk")),
setMatchXml("");
toast.success(t(locale, "watch.importSaved"));
},
onError: () => toast.error(t(locale, "watch.importFail")),
},
);
};
reader.readAsText(file);
},
[importMeta, mediaItemId, locale],
[importXml, mediaItemId, locale],
);
const openRematch = useCallback(() => {
setMatchOpen(true);
matchMut.mutate(
{ mediaItemId },
{
onError: () => {
setMatchOpen(false);
toast.error(t(locale, "watch.matchFailed"));
},
},
);
}, [matchMut, mediaItemId, locale]);
const onSelectCandidate = useCallback(
(episodeId: number) => {
selectMut.mutate(
{ mediaItemId, episodeId },
{
onSuccess: (data) => {
// 手选成功即离开本地源:清内存与 localStorage,避免再粘滞
if (data.ok) {
setLocalXml("");
clearLocalXml(mediaItemId);
}
if (data.ok && data.xml) {
setMatchOpen(false);
setMatchXml(data.xml);
void openDanmaku.refetch();
toast.success(t(locale, "watch.matchManual"));
return;
}
toast.error(data.message ?? t(locale, "watch.matchFailed"));
},
onError: () => toast.error(t(locale, "watch.matchFailed")),
},
);
},
[selectMut, mediaItemId, locale, openDanmaku],
);
const activeFilters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim();
if (!kw) return;
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? { blockKeywords: [], blockTypes: [] };
if (base.blockKeywords.includes(kw)) return base;
return { ...base, blockKeywords: [...base.blockKeywords, kw] };
});
setKwInput("");
}, [kwInput, activeFilters]);
const removeBlockKeyword = useCallback(
(kw: string) => {
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? { blockKeywords: [], blockTypes: [] };
return { ...base, blockKeywords: base.blockKeywords.filter((k) => k !== kw) };
});
},
[activeFilters],
);
const toggleBlockType = useCallback(
(kind: "scroll" | "top" | "bottom") => {
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? {
blockKeywords: [],
blockTypes: [],
};
const blockTypes = base.blockTypes.includes(kind)
? base.blockTypes.filter((k) => k !== kind)
: [...base.blockTypes, kind];
return { ...base, blockTypes };
});
},
[activeFilters],
);
const replay = useCallback(() => {
@ -431,12 +576,35 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
);
}
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
const fetchMessage = openDanmaku.data?.message ?? "";
// 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除)
const showRematch =
danmakuOn &&
(isLocalSource ||
(openDanmaku.data?.source === "none" &&
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"))));
const candidates = matchMut.data?.candidates ?? [];
const mediaTitle = detail.data.title;
const sameNameEpisodes = new Set(
candidates
.filter(
(c) =>
c.animeTitle != null &&
c.animeTitle.length > 0 &&
(mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)),
)
.map((c) => c.episodeId),
);
const sourceLabel = !danmakuOn
? localXml
? localXml || isLocalSource
? t(locale, "watch.danmakuDisabledHint")
: t(locale, "watch.danmakuOff")
: localXml
? t(locale, "watch.danmakuCount", { count: danmakuCount })
: isLocalSource
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
: matchXml
? t(locale, "watch.danmakuOpenNetwork")
: openDanmaku.data?.source === "none"
? t(locale, "watch.noDanmakuImport")
: openDanmaku.data?.source === "cache"
@ -495,10 +663,162 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
)}
</div>
<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")}
</Button>
)}
</span>
<span>{t(locale, "watch.progressHint")}</span>
</div>
<section className="space-y-2 rounded-lg border border-border p-3">
<p className="u-label">{t(locale, "watch.filterTitle")}</p>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t(locale, "danmaku.blockKeywords")}
</p>
<div className="flex flex-wrap items-center gap-2">
{(activeFilters?.blockKeywords ?? []).map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeBlockKeyword(kw)}
>
×
</button>
</span>
))}
<Input
className="h-8 w-40 text-sm"
value={kwInput}
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addBlockKeyword();
}
}}
/>
<Button size="sm" variant="outline" onClick={addBlockKeyword}>
{t(locale, "watch.blockKeywordAdd")}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t(locale, "danmaku.blockTypes")}
</p>
<div className="flex flex-wrap gap-2">
{(
[
["scroll", "danmaku.blockTypeScroll"],
["top", "danmaku.blockTypeTop"],
["bottom", "danmaku.blockTypeBottom"],
] as const
).map(([kind, labelKey]) => {
const blocked = activeFilters?.blockTypes.includes(kind) ?? false;
return (
<button
key={kind}
type="button"
className={cn(
"rounded-md border px-3 py-1.5 text-sm transition-colors",
blocked
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
)}
onClick={() => toggleBlockType(kind)}
>
{t(locale, labelKey)}
</button>
);
})}
</div>
</div>
</section>
{matchOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4 shadow-lg">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">
{t(locale, "watch.matchCandidates")}
</h2>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => setMatchOpen(false)}
>
×
</Button>
</div>
{matchMut.data?.highConfidence && (
<p className="text-xs text-primary">
{t(locale, "watch.matchAutoBound")}
</p>
)}
{matchMut.isPending && (
<p className="text-sm text-muted-foreground">
{t(locale, "watch.loading")}
</p>
)}
{matchMut.isError && (
<p className="text-sm text-destructive">
{t(locale, "watch.matchFailed")}
</p>
)}
{matchMut.isSuccess && candidates.length === 0 && (
<p className="text-sm text-muted-foreground">
{t(locale, "watch.matchEmpty")}
</p>
)}
{candidates.length > 0 && (
<ul className="max-h-80 space-y-2 overflow-y-auto">
{candidates.map((c) => (
<li key={c.episodeId}>
<button
type="button"
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-primary/5"
disabled={selectMut.isPending}
onClick={() => onSelectCandidate(c.episodeId)}
>
<span className="font-medium">
{c.animeTitle ?? `#${c.episodeId}`}
</span>
<span className="ml-2 text-muted-foreground">
{c.episodeTitle ??
t(locale, "watch.episodeN", { n: c.episodeId })}
</span>
<span className="ml-2 text-xs text-muted-foreground">
episodeId {c.episodeId}
</span>
{sameNameEpisodes.has(c.episodeId) && (
<span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
{t(locale, "watch.foundSameName")}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
)}
{episodes.length > 0 && (
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">

View File

@ -1,7 +1,18 @@
import {
filterComments,
parseDanmakuXml,
serializeDanmakuXml,
type DanmakuComment,
type FilterOptions,
} from "@app/danmaku/browser";
export { filterComments, parseDanmakuXml, serializeDanmakuXml };
export type { DanmakuComment, FilterOptions };
/** artplayer-plugin-danmuku 条目(mode 0 滚动 / 1 顶部 / 2 底部) */
export type DanmakuItem = {
time: number;
text: string;
/** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */
mode: 0 | 1 | 2;
color?: string | undefined;
};
@ -19,31 +30,19 @@ function toHexColor(decimal: number): string | undefined {
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
}
/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */
export function parseDanmakuXml(xml: string): DanmakuItem[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const nodes = Array.from(doc.querySelectorAll("d"));
const out: DanmakuItem[] = [];
for (const n of nodes) {
const p = n.getAttribute("p") ?? "";
const [t, modeRaw, , colorRaw] = p.split(",");
const time = Number(t);
if (!Number.isFinite(time)) continue;
const text = (n.textContent ?? "").trim();
if (!text) continue;
out.push({
time,
text,
mode: toPluginMode(Number(modeRaw) || 1),
color: toHexColor(Number(colorRaw)),
});
}
return out.sort((a, b) => a.time - b.time);
/** DanmakuComment(含颜色/模式)→ 插件条目 */
export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] {
return list.map((c) => ({
time: c.time,
text: c.text,
mode: toPluginMode(c.mode),
color: toHexColor(c.color),
}));
}
/** density < 1 时按比例抽稀,避免同屏过密 */
export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] {
if (density >= 1) return items;
/** density 0.1–1:按时间均匀抽稀 */
export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] {
if (density >= 1) return list;
const keepEvery = Math.max(1, Math.round(1 / density));
return items.filter((_, i) => i % keepEvery === 0);
return list.filter((_, i) => i % keepEvery === 0);
}

View File

@ -20,6 +20,7 @@
"docs:dev": "yarn workspace @app/docs dev",
"docs:build": "yarn workspace @app/docs build",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"test:e2e": "playwright test",
"lint": "turbo run lint",
"clean": "turbo run clean",

View File

@ -0,0 +1,29 @@
{
"name": "@app/danmaku",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.15.29",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
}
}

View File

@ -0,0 +1,7 @@
/** 浏览器安全入口:不含 node:crypto(hash/signature)与 fetch 客户端(client)。 */
export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
export { filterComments, modeToKind } from "./filter.js";
export type { FilterOptions } from "./filter.js";
export { parseMatchOutcome, rankCandidates } from "./match.js";
export type { OpenMatchOutcome, RankHint } from "./match.js";
export type { DanmakuComment, MatchCandidate } from "./types.js";

View File

@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasOpenCredentials,
openApiBase,
openCommentXml,
openHeaders,
openMatch,
readOpenCredentials,
} from "./client.js";
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("readOpenCredentials", () => {
it("reads from injected env", () => {
expect(
readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id", OPEN_DANMAKU_APP_SECRET: "s" }),
).toEqual({
appId: "id",
appSecret: "s",
});
});
it("returns null when missing", () => {
expect(readOpenCredentials({})).toBeNull();
expect(readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id" })).toBeNull();
});
it("defaults to process.env", () => {
vi.stubEnv("OPEN_DANMAKU_APP_ID", "pid");
vi.stubEnv("OPEN_DANMAKU_APP_SECRET", "ps");
expect(readOpenCredentials()).toEqual({ appId: "pid", appSecret: "ps" });
expect(hasOpenCredentials()).toBe(true);
});
});
describe("openApiBase", () => {
it("strips trailing slashes", () => {
vi.stubEnv("OPEN_DANMAKU_API_BASE", "https://example.test///");
expect(openApiBase()).toBe("https://example.test");
});
it("defaults to dandanplay api", () => {
vi.stubEnv("OPEN_DANMAKU_API_BASE", undefined);
expect(openApiBase()).toBe("https://api.dandanplay.net");
});
});
describe("openHeaders", () => {
it("signs request when credentials present", () => {
const h = openHeaders("/api/v2/match", { appId: "id", appSecret: "s" });
expect(h["Content-Type"]).toBe("application/json");
expect(h["X-AppId"]).toBe("id");
expect(h["X-Timestamp"]).toMatch(/^\d+$/);
expect(h["X-Signature"]).toBeTruthy();
});
it("only content-type without credentials", () => {
expect(openHeaders("/api/v2/match", null)).toEqual({ "Content-Type": "application/json" });
});
});
function stubFetchJson(json: unknown) {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => json,
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("openCommentXml 请求契约", () => {
it("请求路径含 ?withRelated=true(关联弹幕计入)", async () => {
const fetchMock = stubFetchJson({ success: true, comments: [] });
await openCommentXml(555);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = String(fetchMock.mock.calls[0]?.[0]);
expect(url).toBe("https://api.dandanplay.net/api/v2/comment/555?withRelated=true");
});
it("comments 序列化为 xml,HTTP 失败返回 null", async () => {
const fetchMock = stubFetchJson({
success: true,
comments: [{ p: "12.5,1,0,0,0,0,,", m: "hi" }],
});
await expect(openCommentXml(555)).resolves.toContain("<d ");
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
await expect(openCommentXml(555)).resolves.toBeNull();
});
});
describe("openMatch 请求契约", () => {
it("POST /api/v2/match,matchMode 随 hash 切换", async () => {
const fetchMock = stubFetchJson({ success: true, isMatched: true, matches: [] });
await openMatch("Show - 02", 100, "abc");
const [url1, init1] = fetchMock.mock.calls[0] ?? [];
expect(String(url1)).toBe("https://api.dandanplay.net/api/v2/match");
expect(init1?.method).toBe("POST");
expect(JSON.parse(String(init1?.body))).toEqual({
fileName: "Show - 02",
fileHash: "abc",
fileSize: 100,
matchMode: "hashAndFileName",
});
await openMatch("Show - 02", 100, null);
const init2 = fetchMock.mock.calls[1]?.[1];
expect(JSON.parse(String(init2?.body))).toMatchObject({
fileHash: "",
matchMode: "fileNameOnly",
});
});
});

View File

@ -0,0 +1,97 @@
import { parseMatchOutcome, type OpenMatchOutcome } from "./match.js";
import { generateOpenSignature } from "./signature.js";
import { openCommentsToXml } from "./xml.js";
export type OpenCredentials = { appId: string; appSecret: string };
export function openApiBase(): string {
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
/\/+$/,
"",
);
}
export function readOpenCredentials(env = process.env): OpenCredentials | null {
const appId = env["OPEN_DANMAKU_APP_ID"];
const appSecret = env["OPEN_DANMAKU_APP_SECRET"];
if (!appId || !appSecret) return null;
return { appId, appSecret };
}
export function hasOpenCredentials(): boolean {
return readOpenCredentials() !== null;
}
export function openHeaders(path: string, creds: OpenCredentials | null): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
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),
};
}
export async function openFetchJson(
path: string,
init?: { method?: "POST"; body?: string } | undefined,
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
const creds = readOpenCredentials();
try {
const res = await fetch(`${openApiBase()}${path}`, {
method: init?.method ?? "GET",
headers: openHeaders(path, creds),
...(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 {
success?: boolean;
errorCode?: number;
errorMessage?: string | null;
errorDetail?: string | null;
};
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 网络错误",
};
}
}
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: [] };
return { ok: true, errorMessage: null, ...parseMatchOutcome(res.json) };
}
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?: Array<Record<string, unknown>> };
return openCommentsToXml(body.comments ?? []);
}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { filterComments, modeToKind } from "./filter.js";
import type { DanmakuComment } from "./types.js";
const c = (text: string, mode: number): DanmakuComment => ({
time: 0,
mode,
size: 25,
color: 16777215,
timestamp: 0,
pool: 0,
uid: "u",
rowId: "1",
text,
});
describe("filterComments", () => {
it("drops keyword hits", () => {
const out = filterComments([c("好番", 1), c("剧透狗", 1)], {
blockKeywords: ["剧透"],
blockTypes: [],
});
expect(out.map((x) => x.text)).toEqual(["好番"]);
});
it("drops blocked types (1 scroll, 5 top, 4 bottom)", () => {
const out = filterComments([c("a", 1), c("b", 5), c("c", 4)], {
blockKeywords: [],
blockTypes: ["top"],
});
expect(out.map((x) => x.mode)).toEqual([1, 4]);
});
});
describe("modeToKind", () => {
it("maps modes per dandanplay convention", () => {
expect(modeToKind(1)).toBe("scroll");
expect(modeToKind(2)).toBe("scroll");
expect(modeToKind(3)).toBe("scroll");
expect(modeToKind(6)).toBe("scroll");
expect(modeToKind(4)).toBe("bottom");
expect(modeToKind(5)).toBe("top");
expect(modeToKind(7)).toBe("other");
expect(modeToKind(9)).toBe("other");
});
it("keeps other-kind comments even when blockTypes is full", () => {
const out = filterComments([c("code", 9), c("x", 7)], {
blockKeywords: [],
blockTypes: ["scroll", "top", "bottom"],
});
expect(out.map((x) => x.mode)).toEqual([9, 7]);
});
});

View File

@ -0,0 +1,25 @@
import type { DanmakuComment } from "./types.js";
export type FilterOptions = {
blockKeywords: string[];
blockTypes: Array<"scroll" | "top" | "bottom">;
};
/** mode:1/2/3=滚动 4=底部 5=顶部 6=逆向 7=特殊 9=代码 */
export function modeToKind(mode: number): "scroll" | "top" | "bottom" | "other" {
if (mode === 4) return "bottom";
if (mode === 5) return "top";
if (mode === 1 || mode === 2 || mode === 3 || mode === 6) return "scroll";
return "other";
}
export function filterComments(list: DanmakuComment[], opts: FilterOptions): DanmakuComment[] {
const kinds = new Set(opts.blockTypes);
const kws = opts.blockKeywords.map((k) => k.trim()).filter(Boolean);
return list.filter((c) => {
const kind = modeToKind(c.mode);
if (kind !== "other" && kinds.has(kind)) return false;
if (kws.some((k) => c.text.includes(k))) return false;
return true;
});
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { headMd5, matchFileName } from "./hash.js";
describe("headMd5", () => {
it("md5 of sample", () => {
expect(headMd5(Buffer.from("abc"))).toBe("900150983cd24fb0d6963f7d28e17f72");
});
});
describe("matchFileName", () => {
it("strips folder and extension", () => {
expect(matchFileName("a/b/[Sub] Show - 01.mkv")).toBe("[Sub] Show - 01");
});
it("keeps dotfiles without extension strip", () => {
expect(matchFileName("a/.hidden")).toBe(".hidden");
});
});

View File

@ -0,0 +1,15 @@
import { createHash } from "node:crypto";
export const HASH_HEAD_BYTES = 16 * 1024 * 1024;
/** 文件头部 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;
}

View File

@ -0,0 +1,18 @@
export { generateOpenSignature } from "./signature.js";
export { headMd5, matchFileName, HASH_HEAD_BYTES } from "./hash.js";
export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
export { filterComments, modeToKind } from "./filter.js";
export type { FilterOptions } from "./filter.js";
export { parseMatchOutcome, rankCandidates } from "./match.js";
export type { OpenMatchOutcome, RankHint } from "./match.js";
export {
hasOpenCredentials,
openApiBase,
openCommentXml,
openFetchJson,
openHeaders,
openMatch,
readOpenCredentials,
} from "./client.js";
export type { OpenCredentials } from "./client.js";
export type { DanmakuComment, MatchCandidate } from "./types.js";

View File

@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import { parseMatchOutcome, rankCandidates } from "./match.js";
describe("parseMatchOutcome", () => {
it("maps matches and drops bad episodeId", () => {
const r = parseMatchOutcome({
isMatched: true,
matches: [{ episodeId: 12, animeTitle: "A", episodeTitle: "01" }, { episodeId: "x" }],
});
expect(r.isMatched).toBe(true);
expect(r.candidates).toHaveLength(1);
expect(r.candidates[0]?.episodeId).toBe(12);
});
});
describe("rankCandidates", () => {
it("single candidate is high confidence", () => {
const r = rankCandidates([
{ episodeId: 1, animeId: 1, animeTitle: "A", episodeTitle: "01", imageUrl: null },
]);
expect(r.highConfidence).toBe(true);
expect(r.candidates).toHaveLength(1);
});
it("close scores are not high confidence", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01v2", imageUrl: null },
];
const r = rankCandidates(list);
expect(r.highConfidence).toBe(false);
});
it("empty candidates are not high confidence", () => {
const r = rankCandidates([]);
expect(r.highConfidence).toBe(false);
expect(r.candidates).toEqual([]);
});
it("score gap >= default lead (0.15) is high confidence", () => {
const list = [
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" },
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list);
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(1);
});
it("custom lead is honored", () => {
const list = [
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: null, imageUrl: "img" },
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
];
expect(rankCandidates(list, { lead: 0.5 }).highConfidence).toBe(false);
expect(rankCandidates(list, { lead: 0.3 }).highConfidence).toBe(true);
});
it("returns candidates sorted by score desc", () => {
const hi = {
episodeId: 1,
animeId: 1,
animeTitle: "A",
episodeTitle: "01",
imageUrl: "img",
};
const mid = {
episodeId: 2,
animeId: 1,
animeTitle: "B",
episodeTitle: "02",
imageUrl: null,
};
const low = {
episodeId: 3,
animeId: null,
animeTitle: null,
episodeTitle: null,
imageUrl: "x",
};
const r = rankCandidates([low, mid, hi]);
expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]);
});
it("本地标题包含命中加分:多候选时命中者高置信", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, { hint: { title: "葬送的芙莉莲" } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(2);
});
it("本地标题完全相等加分高于部分包含", () => {
const list = [
{
episodeId: 1,
animeId: 1,
animeTitle: "命运石之门",
episodeTitle: "01",
imageUrl: null,
},
{
episodeId: 2,
animeId: 2,
animeTitle: "命运石之门 负荷领域",
episodeTitle: "01",
imageUrl: null,
},
];
// 本地标题「命运石之门」:候选 1 完全相等(+1),候选 2 为包含(+0.6)
const r = rankCandidates(list, { hint: { title: "命运石之门" } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(1);
});
it("rawName 包含候选标题也加分", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, {
hint: { title: "本地标题", rawName: "[Sub] Sousou no Frieren - 01.mkv" },
});
// rawName 不含任一候选,无加分 → 同分不高置信
expect(r.highConfidence).toBe(false);
const r2 = rankCandidates(list, {
hint: { title: "x", rawName: "[Sub] 芙莉莲 - 01.mkv" },
});
expect(r2.highConfidence).toBe(true);
expect(r2.candidates[0]?.episodeId).toBe(2);
});
it("单字符候选名不参与包含命中,避免噪声加分", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null },
];
const r = rankCandidates(list, {
hint: { title: "Title", rawName: "Title - 01.mkv", epNumber: 1 },
});
expect(r.highConfidence).toBe(false);
});
it("集号相等加分拉开同标题候选差距", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "02", imageUrl: null },
{ episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null },
];
const r = rankCandidates(list, { hint: { title: "Show", epNumber: 1 } });
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(2);
});
it("无 hint 时保持字段存在性打分(默认 lead 0.15)", () => {
const list = [
{ episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null },
{ episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null },
];
expect(rankCandidates(list).highConfidence).toBe(false);
});
});

View File

@ -0,0 +1,113 @@
import type { MatchCandidate } from "./types.js";
export type OpenMatchOutcome = {
ok: boolean;
errorMessage: string | null;
isMatched: boolean;
candidates: MatchCandidate[];
};
type RawMatch = {
episodeId?: number | string;
animeId?: number | string;
animeTitle?: string | null;
episodeTitle?: string | null;
imageUrl?: string | null;
};
/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */
export type RankHint = {
title?: string | undefined;
rawName?: string | undefined;
epNumber?: number | null | undefined;
};
export function parseMatchOutcome(json: unknown): {
isMatched: boolean;
candidates: MatchCandidate[];
} {
const body = json as { isMatched?: boolean; matches?: RawMatch[] };
const candidates: MatchCandidate[] = [];
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 };
}
/** 归一化标题:小写并去掉空白/标点,便于包含关系比对 */
function normalizeText(s: string): string {
return s.toLowerCase().replace(/[\s\-_·・.。!!??:::、,,'"「」『』()()【】[\]]/g, "");
}
/**
* 标题命中加分:完全相等 > 相互包含。
* 双方归一化后至少 2 字符才算命中,避免 "T" 命中 "Title" 这类噪声。
*/
function titleBonus(local: string | undefined, candidate: string | null): number {
if (!local || !candidate) return 0;
const a = normalizeText(local);
const b = normalizeText(candidate);
if (a.length < 2 || b.length < 2) return 0;
if (a === b) return 1;
if (a.includes(b) || b.includes(a)) return 0.6;
return 0;
}
function firstInt(s: string | null): number | null {
if (!s) return null;
const m = /\d+/.exec(s);
const raw = m?.[0];
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/** 集号相等加分(episodeTitle 中首个数字与本地 epNumber 相等) */
function episodeBonus(hint: RankHint | undefined, episodeTitle: string | null): number {
const ep = hint?.epNumber;
if (ep == null || !episodeTitle) return 0;
return firstInt(episodeTitle) === ep ? 0.5 : 0;
}
function scoreCandidate(c: MatchCandidate, hint: RankHint | undefined): number {
const base = (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0);
if (!hint) return base;
const matched = Math.max(
titleBonus(hint.title, c.animeTitle),
titleBonus(hint.rawName, c.animeTitle),
);
return base + matched + episodeBonus(hint, c.episodeTitle);
}
/** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */
export function rankCandidates(
candidates: MatchCandidate[],
opts?: { lead?: number; hint?: RankHint } | undefined,
): { candidates: MatchCandidate[]; highConfidence: boolean } {
const lead = opts?.lead ?? 0.15;
if (candidates.length === 0) return { candidates: [], highConfidence: false };
if (candidates.length === 1) return { candidates, highConfidence: true };
const scored = candidates
.map((c) => ({ c, score: scoreCandidate(c, opts?.hint) }))
.sort((a, b) => b.score - a.score);
const top = scored[0];
const second = scored[1];
if (!top) return { candidates, highConfidence: false };
const high = second ? top.score - second.score >= lead : true;
return { candidates: scored.map((s) => s.c), highConfidence: high };
}

View File

@ -0,0 +1,14 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { generateOpenSignature } from "./signature.js";
describe("generateOpenSignature", () => {
it("base64(sha256(AppId + Timestamp + Path + AppSecret))", () => {
// 固定向量:与现 danmaku.service 算法一致
const sig = generateOpenSignature("app1", 1700000000, "/api/v2/match", "sec");
const expectB64 = createHash("sha256")
.update(`app1${1700000000}/api/v2/matchsec`)
.digest("base64");
expect(sig).toBe(expectB64);
});
});

View File

@ -0,0 +1,11 @@
import { createHash } from "node:crypto";
/** 官方签名: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");
}

View File

@ -0,0 +1,19 @@
export type DanmakuComment = {
time: number;
mode: number;
size: number;
color: number;
timestamp: number;
pool: number;
uid: string;
rowId: string;
text: string;
};
export type MatchCandidate = {
episodeId: number;
animeId: number | null;
animeTitle: string | null;
episodeTitle: string | null;
imageUrl: string | null;
};

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
describe("xml round-trip", () => {
it("parse then serialize preserves time/mode/text", () => {
const xml = openCommentsToXml([{ p: "1.5,1,25,16777215,0,0,u,1", m: "你好<hello>" }]);
expect(xml).toContain('p="1.5,1,25,16777215,0,0,u,1"');
const list = parseDanmakuXml(xml);
expect(list).toHaveLength(1);
expect(list[0]?.time).toBeCloseTo(1.5);
expect(list[0]?.mode).toBe(1);
expect(list[0]?.text).toBe("你好<hello>");
const again = parseDanmakuXml(serializeDanmakuXml(list));
expect(again[0]?.time).toBeCloseTo(1.5);
expect(again[0]?.mode).toBe(1);
expect(again[0]?.size).toBe(25);
expect(again[0]?.color).toBe(16777215);
expect(again[0]?.uid).toBe("u");
expect(again[0]?.rowId).toBe("1");
expect(again[0]?.text).toBe("你好<hello>");
});
});
describe("parseDanmakuXml tolerance", () => {
it("4-segment p falls back size/color/uid/rowId to defaults", () => {
const list = parseDanmakuXml('<?xml version="1.0"?><i><d p="12.5,1,0,0">x</d></i>');
expect(list).toHaveLength(1);
expect(list[0]?.time).toBeCloseTo(12.5);
expect(list[0]?.mode).toBe(1);
expect(list[0]?.size).toBe(0);
expect(list[0]?.color).toBe(0);
expect(list[0]?.timestamp).toBe(0);
expect(list[0]?.pool).toBe(0);
expect(list[0]?.uid).toBe("");
expect(list[0]?.rowId).toBe("");
expect(list[0]?.text).toBe("x");
});
it("shorter p defaults size/color to 0 and mode to 1", () => {
const two = parseDanmakuXml('<?xml version="1.0"?><i><d p="1.5,1">y</d></i>');
expect(two[0]?.time).toBeCloseTo(1.5);
expect(two[0]?.mode).toBe(1);
expect(two[0]?.size).toBe(0);
expect(two[0]?.color).toBe(0);
expect(two[0]?.uid).toBe("");
expect(two[0]?.rowId).toBe("");
const one = parseDanmakuXml('<?xml version="1.0"?><i><d p="3">z</d></i>');
expect(one[0]?.time).toBe(3);
expect(one[0]?.mode).toBe(1);
expect(one[0]?.size).toBe(0);
expect(one[0]?.color).toBe(0);
});
});

112
packages/danmaku/src/xml.ts Normal file
View File

@ -0,0 +1,112 @@
import type { DanmakuComment } from "./types.js";
export type OpenComment = {
p?: string | undefined;
m?: string | undefined;
text?: string | undefined;
mode?: number | undefined;
time?: number | undefined;
};
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function unescapeXml(s: string): string {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
function numOr(v: string | undefined, fallback: number): number {
if (v === undefined || v === "") return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/** `p` = time,mode,size,color,timestamp,pool,uid,rowid;缺省 size/color/timestamp/pool=0,mode 非法时回退 1 */
function parseP(p: string): DanmakuComment {
const seg = unescapeXml(p).split(",");
return {
time: numOr(seg[0], Number.NaN),
mode: numOr(seg[1], 1) || 1,
size: numOr(seg[2], 0),
color: numOr(seg[3], 0),
timestamp: numOr(seg[4], 0),
pool: numOr(seg[5], 0),
uid: seg[6] ?? "",
rowId: seg[7] ?? "",
text: "",
};
}
function toComment(c: OpenComment): DanmakuComment | null {
if (typeof c.p === "string") {
const base = parseP(c.p);
base.text = c.m ?? "";
if (!Number.isFinite(base.time) || !base.text) return null;
return base;
}
const time = Number(c.time);
if (!Number.isFinite(time)) return null;
const text = c.text ?? c.m ?? "";
if (!text) return null;
return {
time,
mode: Number(c.mode) || 1,
size: 0,
color: 0,
timestamp: 0,
pool: 0,
uid: "",
rowId: "",
text,
};
}
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,size,color,timestamp,pool,uid,rowid">text</d>`) */
export function openCommentsToXml(comments: OpenComment[]): string {
const nodes: string[] = [];
for (const c of comments) {
const comment = toComment(c);
if (comment) nodes.push(commentNode(comment));
}
return wrapXml(nodes);
}
function commentNode(c: DanmakuComment): string {
const p = `${c.time},${c.mode},${c.size},${c.color},${c.timestamp},${c.pool},${c.uid},${c.rowId}`;
return `<d p="${escapeXml(p)}">${escapeXml(c.text)}</d>`;
}
function wrapXml(nodes: string[]): string {
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
}
export function parseDanmakuXml(xml: string): DanmakuComment[] {
const list: DanmakuComment[] = [];
for (const m of xml.matchAll(/<d p="([^"]*)">([\s\S]*?)<\/d>/g)) {
const p = m[1];
const body = m[2];
if (p === undefined || body === undefined) continue;
const comment = parseP(p);
comment.text = unescapeXml(body);
if (!Number.isFinite(comment.time)) continue;
list.push(comment);
}
return list;
}
/** 完整 8 段 `p` 序列化,保证与解析互为逆操作 */
export function serializeDanmakuXml(list: DanmakuComment[]): string {
const nodes: string[] = [];
for (const c of list) nodes.push(commentNode(c));
return wrapXml(nodes);
}

View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"],
"paths": {}
},
"include": ["src"]
}

View File

@ -0,0 +1,53 @@
import { and, eq } from "drizzle-orm";
import { danmakuDocs, type DanmakuDocRow } from "@app/models";
import { db } from "@app/db";
export const danmakuDocsDao = {
async get(userId: string, mediaItemId: string, source: string): Promise<DanmakuDocRow | null> {
const [row] = await db
.select()
.from(danmakuDocs)
.where(
and(
eq(danmakuDocs.userId, userId),
eq(danmakuDocs.mediaItemId, mediaItemId),
eq(danmakuDocs.source, source),
),
)
.limit(1);
return row ?? null;
},
async upsert(data: {
userId: string;
mediaItemId: string;
source: string;
xml: string;
byteSize: number;
}): Promise<DanmakuDocRow> {
const existing = await danmakuDocsDao.get(data.userId, data.mediaItemId, data.source);
if (existing) {
const [row] = await db
.update(danmakuDocs)
.set({ xml: data.xml, byteSize: data.byteSize, updatedAt: new Date() })
.where(eq(danmakuDocs.id, existing.id))
.returning();
if (!row) throw new Error("Failed to update danmaku doc");
return row;
}
const rows = await db.insert(danmakuDocs).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to insert danmaku doc");
return row;
},
async remove(userId: string, mediaItemId: string, source: string): Promise<void> {
await db
.delete(danmakuDocs)
.where(
and(
eq(danmakuDocs.userId, userId),
eq(danmakuDocs.mediaItemId, mediaItemId),
eq(danmakuDocs.source, source),
),
);
},
};

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { danmakuPrefs, type DanmakuPrefsRow } from "@app/models";
import { db } from "@app/db";
export const danmakuPrefsDao = {
async getByUser(userId: string): Promise<DanmakuPrefsRow | null> {
const [row] = await db
.select()
.from(danmakuPrefs)
.where(eq(danmakuPrefs.userId, userId))
.limit(1);
return row ?? null;
},
async upsert(data: {
userId: string;
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string;
blockTypes: string;
}): Promise<DanmakuPrefsRow> {
const existing = await db
.select()
.from(danmakuPrefs)
.where(eq(danmakuPrefs.userId, data.userId))
.limit(1);
if (existing[0]) {
const [row] = await db
.update(danmakuPrefs)
.set({ ...data, updatedAt: new Date() })
.where(eq(danmakuPrefs.id, existing[0].id))
.returning();
if (!row) throw new Error("Failed to update danmaku prefs");
return row;
}
const rows = await db.insert(danmakuPrefs).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to insert danmaku prefs");
return row;
},
};

View File

@ -11,3 +11,5 @@ export { mediaItemDao } from "./media-items.js";
export type { MediaItemRow } from "./media-items.js";
export { playbackProgressDao } from "./playback-progress.js";
export { danmakuCacheDao } from "./danmaku-cache.js";
export { danmakuPrefsDao } from "./danmaku-prefs.js";
export { danmakuDocsDao } from "./danmaku-docs.js";

View File

@ -296,13 +296,18 @@ export const mediaItemDao = {
async setDanmakuMatch(
id: string,
userId: string,
data: { episodeId: number; matchedHash?: string | null },
data: {
episodeId: number;
matchedHash?: string | null;
source?: "auto" | "manual" | "none";
},
): Promise<MediaItemRow | null> {
const [row] = await db
.update(mediaItems)
.set({
dandanplayEpisodeId: data.episodeId,
matchedHash: data.matchedHash ?? null,
danmakuMatchSource: data.source ?? "auto",
updatedAt: new Date(),
})
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))

View File

@ -203,6 +203,19 @@ export const en: Record<MessageKey, string> = {
"watch.playbackFinished": "Episode finished",
"watch.danmakuDisabledHint":
"Danmaku is off — enable it via the button above or in danmaku settings",
"watch.danmakuLocal": "Danmaku (local)",
"watch.rematch": "Rematch",
"watch.matchCandidates": "Pick episode",
"watch.matchAutoBound": "High-confidence candidate",
"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",
"watch.matchEmpty": "No episode candidates found",
"watch.blockKeywordPlaceholder": "Type a block word and press Enter",
"danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity",
@ -211,9 +224,19 @@ export const en: Record<MessageKey, string> = {
"danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}%",
"danmaku.save": "Save",
"danmaku.saved": "Saved (local session)",
"danmaku.saved": "Saved",
"danmaku.saveFailed": "Save failed",
"danmaku.localImportHint":
"Import local XML on the watch page; this saves the default toggle and display options.",
"Import local XML on the watch page; this saves server defaults, display options, and block rules.",
"danmaku.blockKeywords": "Blocked words",
"danmaku.blockTypes": "Block types",
"danmaku.blockTypeScroll": "Scroll",
"danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom",
"danmaku.openNetworkNotConfigured":
"Open danmaku network credentials are not configured; online danmaku may be empty.",
"danmaku.serverPrefsHint":
"Preferences are stored on this account; watch-page filters and display follow this config.",
"auth.login.title": "Sign in",
"auth.login.heading": "Sign in",

View File

@ -198,6 +198,19 @@ export const zhCN = {
"watch.replay": "重新播放",
"watch.playbackFinished": "本集已播放完毕",
"watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启",
"watch.danmakuLocal": "弹幕(本地)",
"watch.rematch": "重新匹配",
"watch.matchCandidates": "选择弹幕集",
"watch.matchAutoBound": "高置信候选",
"watch.matchManual": "手动匹配",
"watch.importSaved": "弹幕已保存",
"watch.foundSameName": "同名候选",
"watch.sidecarLinked": "同名弹幕",
"watch.filterTitle": "弹幕过滤",
"watch.blockKeywordAdd": "添加屏蔽词",
"watch.matchFailed": "弹幕匹配失败",
"watch.matchEmpty": "未找到可选手集",
"watch.blockKeywordPlaceholder": "输入屏蔽词后回车",
"danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度",
@ -206,8 +219,17 @@ export const zhCN = {
"danmaku.opacityValue": "不透明度 {value}%",
"danmaku.densityValue": "密度 {value}%",
"danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
"danmaku.saved": "已保存",
"danmaku.saveFailed": "保存失败",
"danmaku.localImportHint":
"本地 XML 导入在播放页操作;此处保存服务端开关、显示参数与屏蔽规则。",
"danmaku.blockKeywords": "屏蔽词",
"danmaku.blockTypes": "类型屏蔽",
"danmaku.blockTypeScroll": "滚动",
"danmaku.blockTypeTop": "顶部",
"danmaku.blockTypeBottom": "底部",
"danmaku.openNetworkNotConfigured": "未配置开放弹幕网络服务端凭据,在线弹幕可能为空。",
"danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。",
"auth.login.title": "登录",
"auth.login.heading": "登录",

View File

@ -0,0 +1,32 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "./users.js";
import { mediaItems } from "./media-items.js";
export const danmakuDocs = sqliteTable(
"danmaku_docs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
mediaItemId: text("media_item_id")
.notNull()
.references(() => mediaItems.id, { onDelete: "cascade" }),
/** `open-network` | `local-xml` */
source: text("source").notNull(),
xml: text("xml").notNull(),
byteSize: integer("byte_size").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(t) => [uniqueIndex("danmaku_docs_user_media_src_uq").on(t.userId, t.mediaItemId, t.source)],
);
export type DanmakuDocRow = typeof danmakuDocs.$inferSelect;
export type NewDanmakuDocRow = typeof danmakuDocs.$inferInsert;

View File

@ -0,0 +1,28 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "./users.js";
export const danmakuPrefs = sqliteTable(
"danmaku_prefs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** 整数百分比 0–100,API 层换算 0–1 小数 */
opacity: integer("opacity").notNull().default(80),
/** 整数百分比 10–100 */
density: integer("density").notNull().default(100),
blockKeywords: text("block_keywords").notNull().default("[]"),
blockTypes: text("block_types").notNull().default("[]"),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(t) => [uniqueIndex("danmaku_prefs_user_uq").on(t.userId)],
);
export type DanmakuPrefsRow = typeof danmakuPrefs.$inferSelect;
export type NewDanmakuPrefsRow = typeof danmakuPrefs.$inferInsert;

View File

@ -13,3 +13,7 @@ export { playbackProgress } from "./playback-progress.js";
export type { PlaybackProgress } from "./playback-progress.js";
export { danmakuCache } from "./danmaku-cache.js";
export type { DanmakuCacheRow } from "./danmaku-cache.js";
export { danmakuPrefs } from "./danmaku-prefs.js";
export type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js";
export { danmakuDocs } from "./danmaku-docs.js";
export type { DanmakuDocRow, NewDanmakuDocRow } from "./danmaku-docs.js";

View File

@ -26,6 +26,8 @@ export const mediaItems = sqliteTable(
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
dandanplayEpisodeId: integer("dandanplay_episode_id"),
matchedHash: text("matched_hash"),
/** auto=高置信自动 | manual=手选 | none */
danmakuMatchSource: text("danmaku_match_source").notNull().default("none"),
scannedAt: integer("scanned_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),

View File

@ -23,6 +23,7 @@
"dev": "tsx watch src/server.ts"
},
"dependencies": {
"@app/danmaku": "workspace:*",
"@app/dao": "workspace:*",
"@app/db": "workspace:*",
"@app/models": "workspace:*",

View File

@ -6,7 +6,7 @@ import {
scrapeSchemas,
} from "@app/types";
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 { mountService } from "../services/mount.service.js";
import { playbackService } from "../services/playback.service.js";
@ -127,18 +127,25 @@ export const mediaRouter = t.router({
danmakuFetch: protectedProcedure
.input(danmakuSchemas.fetch)
.query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)),
danmakuImportMeta: protectedProcedure
.input(danmakuSchemas.importMeta)
danmakuMatch: protectedProcedure
.input(danmakuSchemas.match)
.mutation(({ ctx, input }) =>
danmakuService.importMeta(ctx.userId, input.mediaItemId, input.byteSize),
danmakuService.matchCandidates(ctx.userId, input.mediaItemId),
),
danmakuGetSettings: protectedProcedure.input(danmakuSchemas.getSettings).query(() => ({
enabled: true,
opacity: 0.8,
density: 1,
openNetworkConfigured: hasOpenCredentials(),
})),
danmakuSelectMatch: protectedProcedure
.input(danmakuSchemas.selectMatch)
.mutation(({ ctx, input }) =>
danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId),
),
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
.input(danmakuSchemas.settings)
.mutation(({ input }) => input),
.mutation(({ ctx, input }) => danmakuService.savePrefs(ctx.userId, input)),
});

View File

@ -1,39 +1,432 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
generateOpenSignature,
headMd5,
matchFileName,
openCommentsToXml,
parseMatchOutcome,
} from "./danmaku.service.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku";
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";
describe("danmaku open network helpers", () => {
it("headMd5 matches md5 of sample", () => {
const sample = Buffer.from("hello");
expect(headMd5(sample)).toBe(createHash("md5").update(sample).digest("hex"));
vi.mock("@app/dao", () => ({
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() },
danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() },
mediaItemDao: {
getByIdForUser: vi.fn(),
setDanmakuMatch: 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) => {
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,
};
}
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();
});
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))", () => {
const sig = generateOpenSignature("app", 1735660800, "/api/v2/comment/1", "sec");
const expectSig = createHash("sha256")
.update("app" + "1735660800" + "/api/v2/comment/1" + "sec")
.digest("base64");
expect(sig).toBe(expectSig);
it("媒体不存在抛 NOT_FOUND", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
await expect(danmakuService.importXml("u1", "gone", "<i><d /></i>")).rejects.toMatchObject({
code: "NOT_FOUND",
});
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",
});
});
it("按 UTF-8 字节限 20MB:code unit 未超但字节超限拒绝", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
// 7_000_000 个汉字 = 7M code units(旧实现放行)但 21MB UTF-8
const huge = `<i><d>${"中".repeat(7_000_000)}</d></i>`;
expect(huge.length).toBeLessThanOrEqual(20_000_000);
await expect(danmakuService.importXml("u1", "m1", huge)).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "XML 过大",
});
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
});
});
describe("danmakuService.fetch", () => {
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",
}),
);
});
it("多候选低置信:不自动绑,提示手选", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
vi.mocked(hasOpenCredentials).mockReturnValue(true);
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null);
// 两条同分(score 1.0),lead 0 < 0.15 → highConfidence false
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
{ episodeId: 11, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null },
{ episodeId: 22, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null },
],
});
const r = await danmakuService.fetch("u1", "m1");
expect(r.source).toBe("none");
expect(r.xml).toBe("");
expect(r.message).toContain("手选");
expect(mediaItemDao.setDanmakuMatch).not.toHaveBeenCalled();
expect(openCommentXml).not.toHaveBeenCalled();
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
});
});
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("成功后清除 local-xml,避免本地源粘滞", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
await danmakuService.selectMatch("u1", "m1", 4242);
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
});
it("拉取失败仍保留 manual 绑定,返回 none", async () => {
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();
// 手选即离开本地源:即便拉取失败也清 local-xml
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
});
});
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([
{ p: "12.5,1,16777215", m: "hello" },
{ p: "3,5,16777215", m: "top & <b>" },
]);
expect(xml).toContain('<d p="12.5,1,0,0">hello</d>');
expect(xml).toContain('<d p="3,5,0,0">top &amp; &lt;b&gt;</d>');
expect(xml).toContain('<d p="12.5,1,16777215,0,0,0,,">hello</d>');
expect(xml).toContain('<d p="3,5,16777215,0,0,0,,">top &amp; &lt;b&gt;</d>');
});
it("comments with explicit fields also work", () => {
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", () => {
@ -46,53 +439,212 @@ describe("danmaku open network helpers", () => {
});
});
describe("matchFileName", () => {
it("去文件夹路径与扩展名(官方 match 要求)", () => {
expect(matchFileName("/anime/[Sub] Title - 01 [1080p].mkv")).toBe(
"[Sub] Title - 01 [1080p]",
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("点开头的隐藏文件不算扩展名", () => {
expect(matchFileName(".hidden")).toBe(".hidden");
});
it("无扩展名原样保留", () => {
expect(matchFileName("Movie")).toBe("Movie");
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("parseMatchOutcome", () => {
it("提取候选及其刮削元数据", () => {
const r = parseMatchOutcome({
isMatched: true,
matches: [
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: [
{
episodeId: 111,
animeId: 9,
animeTitle: "葬送的芙莉莲",
episodeTitle: "第1话 冒险终结之地",
imageUrl: "http://img/1.jpg",
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();
});
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("侧车未命中时继续走开放网络前置(缺配置时明示)", 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("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([]);
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

@ -1,227 +1,36 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
import type { DanmakuFetchOutput } from "@app/types";
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
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 { createWebdav, joinWebdavPath } from "./webdav-client.js";
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */
const HASH_HEAD_BYTES = 16 * 1024 * 1024;
/** 同名侧车 XML 全文上限,与 importXml 一致 */
const SIDECAR_MAX_BYTES = 20_000_000;
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(
/\/+$/,
"",
);
/** 媒体路径 → 所在目录("" 表示挂载根)+ 去扩展名 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 };
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 开放网络 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 ?? []);
/** 同 basename + .xml,扩展名与 stem 均大小写不敏感 */
function isSidecarName(fileName: string, base: string): boolean {
return fileName.toLowerCase() === `${base}.xml`.toLowerCase();
}
function matchKeyFor(path: string, size: number): string {
@ -258,12 +67,85 @@ 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 = {
/** 同目录同名 .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: "媒体不存在" };
if (!openCredentials()) {
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,
source: "none",
@ -273,18 +155,25 @@ export const danmakuService = {
};
}
const matchKey = matchKeyFor(item.path, item.size);
const cached = await danmakuCacheDao.getValid(matchKey);
if (cached) {
return { ok: true, source: "cache", xml: cached.payload };
}
const cacheKey =
item.dandanplayEpisodeId != null
? String(item.dandanplayEpisodeId)
: 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;
if (episodeId == null) {
const hash = await remoteFileHash(userId, mediaItemId);
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
const best = outcome.candidates[0];
if (!outcome.ok || !outcome.isMatched || !best) {
const ranked = rankCandidates(outcome.candidates, {
hint: {
title: item.title,
rawName: item.rawName,
epNumber: item.epNumber ?? null,
},
});
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
return {
ok: true,
source: "none",
@ -292,11 +181,23 @@ export const danmakuService = {
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
};
}
episodeId = best.episodeId;
// 识别身份落库:后续播放直接 comment,免重跑 16MB 读流 + match(元数据覆盖走库页识别入口)
// 仅高置信才自动绑;否则等手选(fetch 不落库)
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, {
episodeId,
matchedHash: hash,
source: "auto",
});
}
@ -304,22 +205,128 @@ export const danmakuService = {
if (xml === null) {
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
}
await danmakuCacheDao.upsert({
matchKey,
payload: xml,
source: "open-network",
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
});
await persistOpenXml(userId, mediaItemId, xml);
await persistOpenCache(episodeId, xml);
return { ok: true, source: "open-network", xml };
},
/** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
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, {
hint: {
title: item.title,
rawName: item.rawName,
epNumber: item.epNumber ?? null,
},
});
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",
});
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
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);
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
if (byteSize > 20_000_000) {
// 按 UTF-8 字节限流(Zod 同口径),而非 UTF-16 code unit
if (Buffer.byteLength(xml, "utf8") > 20_000_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
}
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);
},
};

View File

@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { openMatch, hasOpenCredentials } from "@app/danmaku";
import { mediaItemDao } from "@app/dao";
import type { MediaItem } from "@app/models";
import {
AUTO_BIND_THRESHOLD,
buildSearchQuery,
@ -6,6 +9,30 @@ import {
pickDandanEpisode,
scoreHit,
} from "./scrape.service.js";
import { scrapeService } from "./scrape.service.js";
vi.mock("@app/dao", () => ({
mediaItemDao: {
getByIdForUser: vi.fn(),
updateScrape: vi.fn(),
listByPathPrefix: 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(),
openFetchJson: vi.fn(),
};
});
vi.mock("./danmaku.service.js", () => ({
remoteFileHash: vi.fn().mockResolvedValue("hash"),
}));
describe("parseEpisodeFromFilename", () => {
it("S01E02", () => {
@ -89,3 +116,123 @@ describe("scoreHit", () => {
expect(s).toBeLessThan(AUTO_BIND_THRESHOLD);
});
});
function mediaItem(over: Partial<MediaItem> = {}): MediaItem {
return {
id: "m1",
userId: "u1",
mountId: null,
path: "/anime/Show - 01.mkv",
rawName: "Show - 01.mkv",
title: "Show",
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,
};
}
describe("scrapeService.matchDandanplay 绑定一致性", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(hasOpenCredentials).mockReturnValue(true);
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
// score 0:无 animeTitle/episodeTitle → rankCandidates 会排到末尾
{ episodeId: 11, animeId: 1, animeTitle: null, episodeTitle: null, imageUrl: null },
// score 1.0 → ranked 第一名
{ episodeId: 22, animeId: 2, animeTitle: "T", episodeTitle: "E", imageUrl: null },
],
});
});
it("绑定 ranked.candidates[0],且与返回 candidates[0] 一致", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
const r = await scrapeService.matchDandanplay("u1", "m1");
expect(r.matched).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(22);
expect(mediaItemDao.updateScrape).toHaveBeenCalledWith(
"m1",
"u1",
expect.objectContaining({ dandanplayEpisodeId: 22 }),
);
});
it("本地标题命中拉大差距 → 多候选仍高置信自动绑", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
{
episodeId: 11,
animeId: 1,
animeTitle: "Other",
episodeTitle: "X",
imageUrl: null,
},
{
episodeId: 22,
animeId: 2,
animeTitle: "Show",
episodeTitle: "X",
imageUrl: null,
},
],
});
const r = await scrapeService.matchDandanplay("u1", "m1");
expect(r.highConfidence).toBe(true);
expect(r.candidates[0]?.episodeId).toBe(22);
expect(mediaItemDao.updateScrape).toHaveBeenCalledWith(
"m1",
"u1",
expect.objectContaining({ dandanplayEpisodeId: 22 }),
);
});
it("低置信多候选:不写库(不覆盖标题/海报),返回候选供手选", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
mediaItem({ title: "Keep Me", posterUrl: "https://img/keep.jpg" }),
);
vi.mocked(openMatch).mockResolvedValue({
ok: true,
errorMessage: null,
isMatched: true,
candidates: [
{
episodeId: 11,
animeId: 1,
animeTitle: "AAA",
episodeTitle: "X",
imageUrl: "a.jpg",
},
{
episodeId: 22,
animeId: 2,
animeTitle: "BBB",
episodeTitle: "Y",
imageUrl: "b.jpg",
},
],
});
const r = await scrapeService.matchDandanplay("u1", "m1");
expect(r.highConfidence).toBe(false);
expect(r.matched).toBe(false);
expect(r.candidates).toHaveLength(2);
expect(r.message).toContain("手选");
expect(mediaItemDao.updateScrape).not.toHaveBeenCalled();
});
});

View File

@ -1,13 +1,18 @@
import { TRPCError } from "@trpc/server";
import { mediaItemDao } from "@app/dao";
import { parseEpisodeFromFilename, type BatchResult, type DanmakuMatchOutput } from "@app/types";
import {
hasOpenCredentials,
matchFileName,
openFetchJson,
openMatch,
remoteFileHash,
} from "./danmaku.service.js";
rankCandidates,
} from "@app/danmaku";
import {
parseEpisodeFromFilename,
type BatchResult,
type DanmakuMatchOutput,
} from "@app/types";
import { remoteFileHash } from "./danmaku.service.js";
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
export { parseEpisodeFromFilename };
@ -199,6 +204,8 @@ export const scrapeService = {
matched: false,
message:
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
candidates: [],
highConfidence: false,
};
}
@ -209,10 +216,20 @@ export const scrapeService = {
ok: false,
matched: false,
message: outcome.errorMessage ?? "弹弹play 识别失败",
candidates: [],
highConfidence: false,
};
}
const best = outcome.candidates[0];
if (best) {
const ranked = rankCandidates(outcome.candidates, {
hint: {
title: row.title,
rawName: row.rawName,
epNumber: row.epNumber ?? null,
},
});
const best = ranked.candidates[0];
// 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选
if (best && ranked.highConfidence) {
await mediaItemDao.updateScrape(mediaItemId, userId, {
dandanplayEpisodeId: best.episodeId,
matchedHash: hash,
@ -225,13 +242,17 @@ export const scrapeService = {
return {
ok: true,
matched: true,
result: {
episodeId: best.episodeId,
animeId: best.animeId,
animeTitle: best.animeTitle,
episodeTitle: best.episodeTitle,
imageUrl: best.imageUrl,
},
candidates: ranked.candidates,
highConfidence: ranked.highConfidence,
};
}
if (best) {
return {
ok: true,
matched: false,
message: "匹配不明确,请手选弹幕集",
candidates: ranked.candidates,
highConfidence: false,
};
}
@ -251,17 +272,26 @@ export const scrapeService = {
ok: true,
matched: true,
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
result: {
candidates: [
{
episodeId: bridged.episodeId,
animeId: null,
animeTitle: row.title,
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 式「用户选定对应关系」 */

View File

@ -5,6 +5,7 @@
"composite": false,
"types": ["node"],
"paths": {
"@app/danmaku": ["../danmaku/src/index.ts"],
"@app/types": ["../types/src/index.ts"],
"@app/dao": ["../dao/src/index.ts"],
"@app/db": ["../db/src/index.ts"],

View File

@ -14,6 +14,7 @@ export default defineConfig({
},
rollupOptions: {
external: [
"@app/danmaku",
"@app/dao",
"@app/db",
"@app/models",

View File

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { librarySchemas, mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js";
import {
danmakuSchemas,
librarySchemas,
mountSchemas,
playbackSchemas,
scrapeSchemas,
} from "./media-schemas.js";
describe("mountSchemas.create", () => {
it("拒绝缺 baseUrl", () => {
@ -90,3 +96,83 @@ describe("海报墙组级操作 schema", () => {
);
});
});
describe("danmakuSchemas.settings", () => {
it("缺省时填充默认值", () => {
const r = danmakuSchemas.settings.safeParse({});
expect(r.success).toBe(true);
if (!r.success) return;
expect(r.data).toEqual({
enabled: true,
opacity: 0.8,
density: 1,
blockKeywords: [],
blockTypes: [],
});
});
it("blockTypes 只接受 scroll|top|bottom", () => {
expect(
danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] }).success,
).toBe(true);
expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe(false);
});
it("opacity 越界拒绝", () => {
expect(danmakuSchemas.settings.safeParse({ opacity: 1.5 }).success).toBe(false);
});
});
describe("danmakuSchemas.import", () => {
const validXml = `<?xml version="1.0"?><i><d p="0">hi</d></i>`;
it("接受含 <d 的弹幕 XML", () => {
const r = danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: validXml });
expect(r.success).toBe(true);
});
it("拒绝空 xml", () => {
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe(false);
});
it("拒绝不含 <d 的非弹幕内容", () => {
expect(
danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "<html>no</html>" }).success,
).toBe(false);
});
it("拒绝超过 20MB 的 xml", () => {
const huge = `<d ${"a".repeat(20_000_000)}`;
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
false,
);
});
it("按 UTF-8 字节限 20MB:多字节字符 code unit 未超但字节超限拒绝", () => {
// 7_000_000 个汉字 = 7M code units(旧 .max(20_000_000) 放行)但 21MB UTF-8
const huge = `<d p="1,1,0,0,0,0,,">${"中".repeat(7_000_000)}</d>`;
expect(huge.length).toBeLessThanOrEqual(20_000_000);
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
false,
);
});
it("多字节字符字节未超 20MB 时放行", () => {
// 6_000_000 个汉字 ≈ 18MB UTF-8,code units 仅 6M
const ok = `<d p="1,1,0,0,0,0,,">${"中".repeat(6_000_000)}</d>`;
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: ok }).success).toBe(true);
});
it("fileName 超 255 拒绝", () => {
expect(
danmakuSchemas.import.safeParse({
mediaItemId: "m1",
xml: validXml,
fileName: "a".repeat(256),
}).success,
).toBe(false);
});
});
describe("danmakuSchemas.selectMatch", () => {
it("要求正整数 episodeId", () => {
expect(
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 42 }).success,
).toBe(true);
expect(
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 0 }).success,
).toBe(false);
});
});

View File

@ -2,6 +2,24 @@ import { z } from "zod";
const id = z.string().min(1);
/** UTF-8 字节数(等价 TextEncoder/Buffer.byteLength,纯实现,浏览器/Node 均可用) */
function utf8ByteLength(s: string): number {
let bytes = 0;
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff) {
bytes += 4;
i += 1;
} else bytes += 3;
}
return bytes;
}
/** 导入/侧车 XML 全文上限:20MB(UTF-8 字节) */
const XML_MAX_BYTES = 20_000_000;
export const mountSchemas = {
create: z.object({
name: z.string().min(1, "名称不能为空").max(100),
@ -179,17 +197,29 @@ export const scrapeSchemas = {
export const danmakuSchemas = {
/** 开放网络:按媒体文件取弹幕 */
fetch: z.object({ mediaItemId: id }),
/** 手动弹弹play 文件识别(match),命中后写刮削元数据 + 弹幕身份 */
/** 手动识别:返回候选列表,不自动绑 */
match: z.object({ mediaItemId: id }),
/** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */
importMeta: z.object({
/** 手选绑定 episodeId */
selectMatch: z.object({
mediaItemId: id,
byteSize: z.number().min(0).max(20_000_000),
episodeId: z.number().int().positive(),
}),
/** 导入本地 XML 正文(服务端持久化);粗检含弹幕 <d 节点 */
import: z.object({
mediaItemId: id,
xml: z
.string()
.min(1)
.refine((v) => utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB")
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
fileName: z.string().max(255).optional(),
}),
settings: z.object({
enabled: z.boolean().default(true),
opacity: z.number().min(0).max(1).default(0.8),
density: z.number().min(0.1).max(1).default(1),
blockKeywords: z.array(z.string().max(50)).max(100).default([]),
blockTypes: z.array(z.enum(["scroll", "top", "bottom"])).default([]),
}),
getSettings: z.object({}).optional(),
};
@ -214,10 +244,10 @@ export type BatchResult = {
};
export type DanmakuFetchOutput = {
ok: boolean;
source: "open-network" | "cache" | "none";
source: "open-network" | "cache" | "local-xml" | "none";
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
xml: string;
message?: string;
message?: string | undefined;
};
/** 弹弹play match 命中的剧集(MatchResultV2 的刮削子集) */
@ -232,14 +262,17 @@ export type DandanMatchResult = {
export type DanmakuMatchOutput = {
ok: boolean;
matched: boolean;
message?: string;
result?: DandanMatchResult;
message?: string | undefined;
candidates: DandanMatchResult[];
highConfidence: boolean;
};
export type DanmakuSettingsOutput = {
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string[];
blockTypes: Array<"scroll" | "top" | "bottom">;
/** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */
openNetworkConfigured: boolean;
};

View File

@ -27,6 +27,10 @@
"lint": {
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"db:generate": {
"cache": false,
"dependsOn": ["^build"]

View File

@ -221,6 +221,16 @@ __metadata:
languageName: unknown
linkType: soft
"@app/danmaku@workspace:*, @app/danmaku@workspace:packages/danmaku":
version: 0.0.0-use.local
resolution: "@app/danmaku@workspace:packages/danmaku"
dependencies:
"@types/node": "npm:^22.15.29"
typescript: "npm:^7.0.2"
vitest: "npm:^5.0.1"
languageName: unknown
linkType: soft
"@app/dao@workspace:*, @app/dao@workspace:packages/dao":
version: 0.0.0-use.local
resolution: "@app/dao@workspace:packages/dao"
@ -304,6 +314,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@app/trpc@workspace:packages/trpc"
dependencies:
"@app/danmaku": "workspace:*"
"@app/dao": "workspace:*"
"@app/db": "workspace:*"
"@app/models": "workspace:*"
@ -366,6 +377,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@app/web@workspace:app/web"
dependencies:
"@app/danmaku": "workspace:*"
"@app/design-tokens": "workspace:*"
"@app/i18n": "workspace:*"
"@app/seo-geo": "workspace:*"