diff --git a/AGENTS.md b/AGENTS.md index 86ad5a4..63d4f85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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(依赖拓扑) diff --git a/app/web/package.json b/app/web/package.json index de9683e..a92a3e8 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -12,6 +12,7 @@ "lint": "biome check ." }, "dependencies": { + "@app/danmaku": "workspace:*", "@app/design-tokens": "workspace:*", "@app/i18n": "workspace:*", "@app/seo-geo": "workspace:*", diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx index 5ac2e36..c1ba8c1 100644 --- a/app/web/src/components/pages/DanmakuSettingsBody.tsx +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -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; + +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; 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(() => []); + const [blockTypes, setBlockTypes] = useState(() => []); + 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 (

{t(locale, "danmaku.title")}

+ {openNetworkMissing && ( +
+ {t(locale, "danmaku.openNetworkNotConfigured")} +
+ )}
+
+

{t(locale, "danmaku.blockKeywords")}

+
+ {blockKeywords.map((kw) => ( + + {kw} + + + ))} + setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addBlockKeyword(); + } + }} + /> + +
+
+
+

{t(locale, "danmaku.blockTypes")}

+
+ {BLOCK_TYPE_LABELS.map(([kind, labelKey]) => ( + + ))} +
+
- - {save.isSuccess && ( -

{t(locale, "danmaku.saved")}

- )}
+

+ {t(locale, "danmaku.serverPrefsHint")} +

{t(locale, "danmaku.localImportHint")}

diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index d1971dc..52dd80e 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -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; 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(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(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 ?? ""); - setLocalXml(text); - writeLocalXml(mediaItemId, text); - importMeta.mutate( - { mediaItemId, byteSize: file.size }, + importXml.mutate( + { mediaItemId, xml: text, fileName: file.name }, { - onSuccess: () => toast.success(t(locale, "watch.importOk")), + onSuccess: () => { + setLocalXml(text); + writeLocalXml(mediaItemId, text); + 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,19 +576,42 @@ 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 }) - : openDanmaku.data?.source === "none" - ? t(locale, "watch.noDanmakuImport") - : openDanmaku.data?.source === "cache" - ? t(locale, "watch.danmakuCache") - : openDanmaku.data?.source === "open-network" - ? t(locale, "watch.danmakuOpenNetwork") - : t(locale, "watch.danmakuLoading"); + : 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" + ? t(locale, "watch.danmakuCache") + : openDanmaku.data?.source === "open-network" + ? t(locale, "watch.danmakuOpenNetwork") + : t(locale, "watch.danmakuLoading"); return (
@@ -495,10 +663,162 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { )}
- {sourceLabel} + + {sourceLabel} + {isLocalSource && fetchMessage.includes("同名") && ( + + {t(locale, "watch.sidecarLinked")} + + )} + {showRematch && ( + + )} + {t(locale, "watch.progressHint")}
+
+

{t(locale, "watch.filterTitle")}

+
+

+ {t(locale, "danmaku.blockKeywords")} +

+
+ {(activeFilters?.blockKeywords ?? []).map((kw) => ( + + {kw} + + + ))} + setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addBlockKeyword(); + } + }} + /> + +
+

+ {t(locale, "danmaku.blockTypes")} +

+
+ {( + [ + ["scroll", "danmaku.blockTypeScroll"], + ["top", "danmaku.blockTypeTop"], + ["bottom", "danmaku.blockTypeBottom"], + ] as const + ).map(([kind, labelKey]) => { + const blocked = activeFilters?.blockTypes.includes(kind) ?? false; + return ( + + ); + })} +
+
+
+ + {matchOpen && ( +
+
+
+

+ {t(locale, "watch.matchCandidates")} +

+ +
+ {matchMut.data?.highConfidence && ( +

+ {t(locale, "watch.matchAutoBound")} +

+ )} + {matchMut.isPending && ( +

+ {t(locale, "watch.loading")} +

+ )} + {matchMut.isError && ( +

+ {t(locale, "watch.matchFailed")} +

+ )} + {matchMut.isSuccess && candidates.length === 0 && ( +

+ {t(locale, "watch.matchEmpty")} +

+ )} + {candidates.length > 0 && ( +
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} + {episodes.length > 0 && (
diff --git a/app/web/src/lib/danmaku-render.ts b/app/web/src/lib/danmaku-render.ts index 1530aaa..3c66bcb 100644 --- a/app/web/src/lib/danmaku-render.ts +++ b/app/web/src/lib/danmaku-render.ts @@ -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); } diff --git a/package.json b/package.json index ea9bb44..bd5a7bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/danmaku/package.json b/packages/danmaku/package.json new file mode 100644 index 0000000..ec61ca1 --- /dev/null +++ b/packages/danmaku/package.json @@ -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" + } +} diff --git a/packages/danmaku/src/browser.ts b/packages/danmaku/src/browser.ts new file mode 100644 index 0000000..153fa1d --- /dev/null +++ b/packages/danmaku/src/browser.ts @@ -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"; diff --git a/packages/danmaku/src/client.test.ts b/packages/danmaku/src/client.test.ts new file mode 100644 index 0000000..10ccc68 --- /dev/null +++ b/packages/danmaku/src/client.test.ts @@ -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(" ({}) }); + 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", + }); + }); +}); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts new file mode 100644 index 0000000..c8cedad --- /dev/null +++ b/packages/danmaku/src/client.ts @@ -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 { + const headers: Record = { "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 { + 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 { + const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`); + if (!res.ok) return null; + const body = res.json as { comments?: Array> }; + return openCommentsToXml(body.comments ?? []); +} diff --git a/packages/danmaku/src/filter.test.ts b/packages/danmaku/src/filter.test.ts new file mode 100644 index 0000000..c31d71a --- /dev/null +++ b/packages/danmaku/src/filter.test.ts @@ -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]); + }); +}); diff --git a/packages/danmaku/src/filter.ts b/packages/danmaku/src/filter.ts new file mode 100644 index 0000000..e5976cd --- /dev/null +++ b/packages/danmaku/src/filter.ts @@ -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; + }); +} diff --git a/packages/danmaku/src/hash.test.ts b/packages/danmaku/src/hash.test.ts new file mode 100644 index 0000000..f0a171a --- /dev/null +++ b/packages/danmaku/src/hash.test.ts @@ -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"); + }); +}); diff --git a/packages/danmaku/src/hash.ts b/packages/danmaku/src/hash.ts new file mode 100644 index 0000000..fbcd0b5 --- /dev/null +++ b/packages/danmaku/src/hash.ts @@ -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; +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts new file mode 100644 index 0000000..59cfa9b --- /dev/null +++ b/packages/danmaku/src/index.ts @@ -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"; diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts new file mode 100644 index 0000000..45adf47 --- /dev/null +++ b/packages/danmaku/src/match.test.ts @@ -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); + }); +}); diff --git a/packages/danmaku/src/match.ts b/packages/danmaku/src/match.ts new file mode 100644 index 0000000..e05e2ba --- /dev/null +++ b/packages/danmaku/src/match.ts @@ -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 }; +} diff --git a/packages/danmaku/src/signature.test.ts b/packages/danmaku/src/signature.test.ts new file mode 100644 index 0000000..47501b3 --- /dev/null +++ b/packages/danmaku/src/signature.test.ts @@ -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); + }); +}); diff --git a/packages/danmaku/src/signature.ts b/packages/danmaku/src/signature.ts new file mode 100644 index 0000000..209eda8 --- /dev/null +++ b/packages/danmaku/src/signature.ts @@ -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"); +} diff --git a/packages/danmaku/src/types.ts b/packages/danmaku/src/types.ts new file mode 100644 index 0000000..b058d1c --- /dev/null +++ b/packages/danmaku/src/types.ts @@ -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; +}; diff --git a/packages/danmaku/src/xml.test.ts b/packages/danmaku/src/xml.test.ts new file mode 100644 index 0000000..5f78a9d --- /dev/null +++ b/packages/danmaku/src/xml.test.ts @@ -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: "你好" }]); + 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("你好"); + 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("你好"); + }); +}); + +describe("parseDanmakuXml tolerance", () => { + it("4-segment p falls back size/color/uid/rowId to defaults", () => { + const list = parseDanmakuXml('x'); + 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('y'); + 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('z'); + expect(one[0]?.time).toBe(3); + expect(one[0]?.mode).toBe(1); + expect(one[0]?.size).toBe(0); + expect(one[0]?.color).toBe(0); + }); +}); diff --git a/packages/danmaku/src/xml.ts b/packages/danmaku/src/xml.ts new file mode 100644 index 0000000..0560e5b --- /dev/null +++ b/packages/danmaku/src/xml.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function unescapeXml(s: string): string { + return s + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/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(`text`) */ +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 `${escapeXml(c.text)}`; +} + +function wrapXml(nodes: string[]): string { + return `${nodes.join("")}`; +} + +export function parseDanmakuXml(xml: string): DanmakuComment[] { + const list: DanmakuComment[] = []; + for (const m of xml.matchAll(/([\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); +} diff --git a/packages/danmaku/tsconfig.build.json b/packages/danmaku/tsconfig.build.json new file mode 100644 index 0000000..f6a099b --- /dev/null +++ b/packages/danmaku/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/danmaku/tsconfig.json b/packages/danmaku/tsconfig.json new file mode 100644 index 0000000..f83471c --- /dev/null +++ b/packages/danmaku/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "paths": {} + }, + "include": ["src"] +} diff --git a/packages/dao/src/danmaku-docs.ts b/packages/dao/src/danmaku-docs.ts new file mode 100644 index 0000000..4d5a653 --- /dev/null +++ b/packages/dao/src/danmaku-docs.ts @@ -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 { + 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 { + 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 { + await db + .delete(danmakuDocs) + .where( + and( + eq(danmakuDocs.userId, userId), + eq(danmakuDocs.mediaItemId, mediaItemId), + eq(danmakuDocs.source, source), + ), + ); + }, +}; diff --git a/packages/dao/src/danmaku-prefs.ts b/packages/dao/src/danmaku-prefs.ts new file mode 100644 index 0000000..0ff2c03 --- /dev/null +++ b/packages/dao/src/danmaku-prefs.ts @@ -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 { + 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 { + 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; + }, +}; diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index 8edc9fb..a537aaf 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -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"; diff --git a/packages/dao/src/media-items.ts b/packages/dao/src/media-items.ts index d47b391..f2977af 100644 --- a/packages/dao/src/media-items.ts +++ b/packages/dao/src/media-items.ts @@ -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 { 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))) diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index f2e6b6e..cf3cd18 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -203,6 +203,19 @@ export const en: Record = { "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 = { "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", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 6a0e9e9..62f0547 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -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": "登录", diff --git a/packages/models/src/danmaku-docs.ts b/packages/models/src/danmaku-docs.ts new file mode 100644 index 0000000..0d8b9cd --- /dev/null +++ b/packages/models/src/danmaku-docs.ts @@ -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; diff --git a/packages/models/src/danmaku-prefs.ts b/packages/models/src/danmaku-prefs.ts new file mode 100644 index 0000000..48c70a5 --- /dev/null +++ b/packages/models/src/danmaku-prefs.ts @@ -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; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index c8da20f..91cbf33 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -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"; diff --git a/packages/models/src/media-items.ts b/packages/models/src/media-items.ts index 23f1db6..afd0183 100644 --- a/packages/models/src/media-items.ts +++ b/packages/models/src/media-items.ts @@ -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())`), diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 8c2b16f..e2664bf 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -23,6 +23,7 @@ "dev": "tsx watch src/server.ts" }, "dependencies": { + "@app/danmaku": "workspace:*", "@app/dao": "workspace:*", "@app/db": "workspace:*", "@app/models": "workspace:*", diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index 9caef74..394f700 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -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)), }); diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 9b68fad..453d077 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -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(); + return { + ...actual, + createWebdav: vi.fn(), + }; +}); + +vi.mock("@app/danmaku", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openMatch: vi.fn(), + openCommentXml: vi.fn(), + hasOpenCredentials: vi.fn(), + }; +}); + +function mediaItem(over: Partial = {}): 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 { + return { + id: "d1", + userId: "u1", + mediaItemId: "m1", + source: "local-xml", + xml: "local", + byteSize: 12, + createdAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +function cacheRow(over: Partial = {}): DanmakuCacheRow { + return { + id: "c1", + matchKey: "4242", + payload: "cached", + source: "open-network", + expiresAt: null, + createdAt: new Date(), + ...over, + }; +} + +function prefsRow(over: Partial = {}): DanmakuPrefsRow { + return { + id: "p1", + userId: "u1", + enabled: true, + opacity: 80, + density: 100, + blockKeywords: "[]", + blockTypes: "[]", + updatedAt: new Date(), + ...over, + }; +} + +function mountRow(over: Partial = {}): 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 粗算 { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + const xml = + 'ab'; + 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", "")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect(danmakuDocsDao.upsert).not.toHaveBeenCalled(); }); - it("comments p/m maps to bilibili xml", () => { + it("无 { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + await expect(danmakuService.importXml("u1", "m1", "")).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 = `${"中".repeat(7_000_000)}`; + 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: "local" })); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" }); + 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: "cached" }), + ); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "cache", xml: "cached" }); + 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("open"); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + 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: "open" }), + ); + expect(danmakuCacheDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + matchKey: "555", + payload: "open", + 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("open"); + const r = await danmakuService.selectMatch("u1", "m1", 4242); + expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { + episodeId: 4242, + matchedHash: "abc", + source: "manual", + }); + expect(danmakuCacheDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ matchKey: "4242", payload: "open" }), + ); + expect(danmakuDocsDao.upsert).toHaveBeenCalledWith( + expect.objectContaining({ source: "open-network", xml: "open" }), + ); + }); + + it("成功后清除 local-xml,避免本地源粘滞", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(openCommentXml).mockResolvedValue("open"); + 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 & " }, ]); - expect(xml).toContain('hello'); - expect(xml).toContain('top & <b>'); + expect(xml).toContain('hello'); + expect(xml).toContain('top & <b>'); }); it("comments with explicit fields also work", () => { const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]); - expect(xml).toContain('x'); + expect(xml).toContain('x'); }); 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 = 'hi'; + + function setupMounted(over: Partial = {}) { + 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("扩展名 .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("无扩展名原样保留", () => { - expect(matchFileName("Movie")).toBe("Movie"); + + 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("非弹幕粗检(无 { + 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: "not danmaku", + }) 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: [ - { - episodeId: 111, - animeId: 9, - animeTitle: "葬送的芙莉莲", - episodeTitle: "第1话 冒险终结之地", - imageUrl: "http://img/1.jpg", - }, - ], - }); - expect(r.isMatched).toBe(true); - expect(r.candidates[0]?.episodeId).toBe(111); - expect(r.candidates[0]?.animeId).toBe(9); - expect(r.candidates[0]?.animeTitle).toBe("葬送的芙莉莲"); - expect(r.candidates[0]?.imageUrl).toBe("http://img/1.jpg"); +describe("danmakuService.fetch 同名侧车", () => { + const sidecarXml = 'side'; + + it("无 local-xml 时自动关联侧车:返回 local-xml 且 message 含同名提示", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ mountId: "mnt1", path: "/anime/Show - 01.mkv" }), + ); + vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow()); + vi.mocked(createWebdav).mockReturnValue( + fakeWebdavClient({ + entries: [ + { + filename: "/library/anime/Show - 01.xml", + basename: "Show - 01.xml", + type: "file", + size: sidecarXml.length, + }, + ], + contents: sidecarXml, + }) as never, + ); + vi.mocked(danmakuDocsDao.get) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(docRow({ xml: sidecarXml })); + const r = await danmakuService.fetch("u1", "m1"); + expect(r.ok).toBe(true); + expect(r.source).toBe("local-xml"); + expect(r.xml).toBe(sidecarXml); + expect(r.message).toContain("同名"); + expect(openMatch).not.toHaveBeenCalled(); }); - it("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("侧车未命中时继续走开放网络前置(缺配置时明示)", 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("空响应安全兜底", () => { - 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: "local" })); + const r = await danmakuService.fetch("u1", "m1"); + expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" }); + expect(createWebdav).not.toHaveBeenCalled(); }); }); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 322fac4..0d2a5cd 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -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, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** 开放网络 comments → Bilibili 式 XML(`text`) */ -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(`${escapeXml(text)}`); - } - return `${nodes.join("")}`; -} - -function openHeaders(path: string): Record { - const headers: Record = { "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 { - 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 { - 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 { + await danmakuDocsDao.upsert({ + userId, + mediaItemId, + source: "open-network", + xml, + byteSize: Buffer.byteLength(xml, "utf8"), + }); +} + +async function persistOpenCache(episodeId: number, xml: string): Promise { + 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 { + 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(" { 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 { + 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 { + 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(" { + 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 { + 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); }, }; diff --git a/packages/trpc/src/services/scrape.service.test.ts b/packages/trpc/src/services/scrape.service.test.ts index f379cca..d1064b5 100644 --- a/packages/trpc/src/services/scrape.service.test.ts +++ b/packages/trpc/src/services/scrape.service.test.ts @@ -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(); + 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 { + 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(); + }); +}); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index a818287..f57643a 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -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: { - episodeId: bridged.episodeId, - animeId: null, - animeTitle: row.title, - episodeTitle: bridged.episodeTitle, - imageUrl: row.posterUrl, - }, + 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 式「用户选定对应关系」 */ diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json index b00e666..2fd432f 100644 --- a/packages/trpc/tsconfig.json +++ b/packages/trpc/tsconfig.json @@ -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"], diff --git a/packages/trpc/vite.config.ts b/packages/trpc/vite.config.ts index 283d5fa..e6db1c1 100644 --- a/packages/trpc/vite.config.ts +++ b/packages/trpc/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ }, rollupOptions: { external: [ + "@app/danmaku", "@app/dao", "@app/db", "@app/models", diff --git a/packages/types/src/media-schemas.test.ts b/packages/types/src/media-schemas.test.ts index 7e5777d..61a783a 100644 --- a/packages/types/src/media-schemas.test.ts +++ b/packages/types/src/media-schemas.test.ts @@ -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 = `hi`; + + it("接受含 { + 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("拒绝不含 { + expect( + danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "no" }).success, + ).toBe(false); + }); + it("拒绝超过 20MB 的 xml", () => { + const huge = ` { + // 7_000_000 个汉字 = 7M code units(旧 .max(20_000_000) 放行)但 21MB UTF-8 + const huge = `${"中".repeat(7_000_000)}`; + 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 = `${"中".repeat(6_000_000)}`; + 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); + }); +}); diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index 6bda883..14baa68 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -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 正文(服务端持久化);粗检含弹幕 utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB") + .refine((v) => v.includes("; /** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */ openNetworkConfigured: boolean; }; diff --git a/turbo.json b/turbo.json index 9149789..1d0dce3 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,10 @@ "lint": { "outputs": [] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "db:generate": { "cache": false, "dependsOn": ["^build"] diff --git a/yarn.lock b/yarn.lock index b9dacdc..8d60186 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"