Merge branch 'feat/dandanplay-alignment'
# Conflicts: # app/web/src/components/pages/WatchBody.tsx # packages/trpc/src/services/scrape.service.ts # packages/types/src/media-schemas.test.ts
This commit is contained in:
commit
5f5dd968cb
10
AGENTS.md
10
AGENTS.md
|
|
@ -13,11 +13,12 @@
|
||||||
| 包 | 职责 | 依赖 |
|
| 包 | 职责 | 依赖 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
|
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
|
||||||
|
| `@app/danmaku` | 弹幕开放协议纯函数(签名/hash/XML/匹配/过滤;`./browser` 无 node:crypto) | — |
|
||||||
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
|
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
|
||||||
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
|
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
|
||||||
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
|
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
|
||||||
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
|
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao, danmaku |
|
||||||
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
|
| `@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/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
|
||||||
| `@app/design-tokens` | 共享 CSS 设计令牌(`tokens.css`) | — |
|
| `@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 |
|
| `@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
|
web ──► trpc ──► dao ──► db ──► models
|
||||||
│ │ │
|
│ │ │ │
|
||||||
|
│ │ └──► danmaku │
|
||||||
│ └─────────────────┴──► types
|
│ └─────────────────┴──► types
|
||||||
|
├──► danmaku(浏览器侧走 ./browser)
|
||||||
├──► design-tokens
|
├──► design-tokens
|
||||||
├──► seo-geo
|
├──► seo-geo
|
||||||
├──► i18n
|
├──► i18n
|
||||||
|
|
@ -55,6 +58,7 @@ docs(独立,不依赖上述业务包)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
yarn install
|
||||||
|
yarn test # 各包 vitest(turbo run test)
|
||||||
yarn typecheck # 全仓 typecheck
|
yarn typecheck # 全仓 typecheck
|
||||||
yarn lint # Biome
|
yarn lint # Biome
|
||||||
yarn build # 全仓 build(依赖拓扑)
|
yarn build # 全仓 build(依赖拓扑)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"lint": "biome check ."
|
"lint": "biome check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@app/danmaku": "workspace:*",
|
||||||
"@app/design-tokens": "workspace:*",
|
"@app/design-tokens": "workspace:*",
|
||||||
"@app/i18n": "workspace:*",
|
"@app/i18n": "workspace:*",
|
||||||
"@app/seo-geo": "workspace:*",
|
"@app/seo-geo": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,158 @@
|
||||||
import type { Locale } from "@app/i18n";
|
import type { Locale, MessageKey } from "@app/i18n";
|
||||||
import { t } 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { go } from "~/lib/nav";
|
import { go } from "~/lib/nav";
|
||||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
|
|
||||||
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||||||
|
|
||||||
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
|
type BlockType = "scroll" | "top" | "bottom";
|
||||||
|
|
||||||
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
|
type DanmakuSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
opacity: number;
|
||||||
|
density: number;
|
||||||
|
blockKeywords: string[];
|
||||||
|
blockTypes: BlockType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK: DanmakuSettings = {
|
||||||
|
enabled: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
density: 1,
|
||||||
|
blockKeywords: [],
|
||||||
|
blockTypes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_TYPE_LABELS = [
|
||||||
|
["scroll", "danmaku.blockTypeScroll"],
|
||||||
|
["top", "danmaku.blockTypeTop"],
|
||||||
|
["bottom", "danmaku.blockTypeBottom"],
|
||||||
|
] as const satisfies ReadonlyArray<readonly [BlockType, MessageKey]>;
|
||||||
|
|
||||||
|
function parseBlockTypes(raw: unknown): BlockType[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const allowed: BlockType[] = ["scroll", "top", "bottom"];
|
||||||
|
return raw.filter((v): v is BlockType => allowed.includes(v as BlockType));
|
||||||
|
}
|
||||||
|
|
||||||
function readSettings(): DanmakuSettings {
|
function readSettings(): DanmakuSettings {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
|
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
|
||||||
if (!raw) return FALLBACK;
|
if (!raw) return { ...FALLBACK };
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (typeof parsed !== "object" || parsed === null) return FALLBACK;
|
if (typeof parsed !== "object" || parsed === null) return { ...FALLBACK };
|
||||||
const rec = parsed as Record<string, unknown>;
|
const rec = parsed as Record<string, unknown>;
|
||||||
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
|
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
|
||||||
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
|
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
|
||||||
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density;
|
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 {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
opacity: Math.min(1, Math.max(0, opacityRaw)),
|
opacity: Math.min(1, Math.max(0, opacityRaw)),
|
||||||
density: Math.min(1, Math.max(0.1, densityRaw)),
|
density: Math.min(1, Math.max(0.1, densityRaw)),
|
||||||
|
blockKeywords: blockKeywords.slice(0, 100),
|
||||||
|
blockTypes: parseBlockTypes(rec["blockTypes"]),
|
||||||
};
|
};
|
||||||
} catch {
|
} 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 }) {
|
function Inner(props: { locale: Locale }) {
|
||||||
const { locale } = props;
|
const { locale } = props;
|
||||||
|
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
|
||||||
const save = trpc.media.danmakuSaveSettings.useMutation();
|
const save = trpc.media.danmakuSaveSettings.useMutation();
|
||||||
|
const filledFromServer = useRef(false);
|
||||||
|
|
||||||
|
// SSR 与水合首帧共用 FALLBACK,避免 localStorage 差异导致 hydration 不一致
|
||||||
const [enabled, setEnabled] = useState(FALLBACK.enabled);
|
const [enabled, setEnabled] = useState(FALLBACK.enabled);
|
||||||
const [opacity, setOpacity] = useState(FALLBACK.opacity);
|
const [opacity, setOpacity] = useState(FALLBACK.opacity);
|
||||||
const [density, setDensity] = useState(FALLBACK.density);
|
const [density, setDensity] = useState(FALLBACK.density);
|
||||||
|
const [blockKeywords, setBlockKeywords] = useState<string[]>(() => []);
|
||||||
|
const [blockTypes, setBlockTypes] = useState<BlockType[]>(() => []);
|
||||||
|
const [kwInput, setKwInput] = useState("");
|
||||||
|
|
||||||
|
// 挂载后再读 localStorage 作初值/回退(客户端 only)
|
||||||
|
useEffect(() => {
|
||||||
|
const local = readSettings();
|
||||||
|
if (filledFromServer.current) return;
|
||||||
|
setEnabled(local.enabled);
|
||||||
|
setOpacity(local.opacity);
|
||||||
|
setDensity(local.density);
|
||||||
|
setBlockKeywords(local.blockKeywords);
|
||||||
|
setBlockTypes(local.blockTypes);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prefs = readSettings();
|
const data = prefsQuery.data;
|
||||||
setEnabled(prefs.enabled);
|
if (!data || filledFromServer.current) return;
|
||||||
setOpacity(prefs.opacity);
|
filledFromServer.current = true;
|
||||||
setDensity(prefs.density);
|
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 onSave = () => {
|
||||||
const next = { enabled, opacity, density };
|
const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes };
|
||||||
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
|
writeSettings(next);
|
||||||
save.mutate(next, {
|
save.mutate(next, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t(locale, "danmaku.saved"));
|
toast.success(t(locale, "danmaku.saved"));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t(locale, "danmaku.save"));
|
toast.error(t(locale, "danmaku.saveFailed"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
|
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
|
||||||
|
{openNetworkMissing && (
|
||||||
|
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
{t(locale, "danmaku.openNetworkNotConfigured")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Card className="max-w-md space-y-5 p-6">
|
<Card className="max-w-md space-y-5 p-6">
|
||||||
<label className="flex items-center gap-2.5 text-sm font-medium">
|
<label className="flex items-center gap-2.5 text-sm font-medium">
|
||||||
<input
|
<input
|
||||||
|
|
@ -105,14 +196,67 @@ function Inner(props: { locale: Locale }) {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t(locale, "danmaku.blockKeywords")}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{blockKeywords.map((kw) => (
|
||||||
|
<span
|
||||||
|
key={kw}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => removeBlockKeyword(kw)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
className="h-8 w-40 text-sm"
|
||||||
|
value={kwInput}
|
||||||
|
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
|
||||||
|
onChange={(e) => setKwInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addBlockKeyword();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={addBlockKeyword}>
|
||||||
|
{t(locale, "watch.blockKeywordAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t(locale, "danmaku.blockTypes")}</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{BLOCK_TYPE_LABELS.map(([kind, labelKey]) => (
|
||||||
|
<label
|
||||||
|
key={kind}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={blockTypes.includes(kind)}
|
||||||
|
onChange={() => toggleBlockType(kind)}
|
||||||
|
/>
|
||||||
|
{t(locale, labelKey)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
<Button type="button" onClick={onSave}>
|
<Button type="button" onClick={onSave} disabled={save.isPending}>
|
||||||
{t(locale, "danmaku.save")}
|
{t(locale, "danmaku.save")}
|
||||||
</Button>
|
</Button>
|
||||||
{save.isSuccess && (
|
|
||||||
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{t(locale, "danmaku.serverPrefsHint")}
|
||||||
|
</p>
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
{t(locale, "danmaku.localImportHint")}
|
{t(locale, "danmaku.localImportHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import type { Locale } from "@app/i18n";
|
import type { Locale } from "@app/i18n";
|
||||||
import { t } from "@app/i18n";
|
import { t } from "@app/i18n";
|
||||||
import { parseEpisodeFromFilename } from "@app/types";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Artplayer from "artplayer";
|
import Artplayer from "artplayer";
|
||||||
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
|
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { go, queryParam } from "~/lib/nav";
|
||||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
@ -19,6 +25,7 @@ type DanmukuPlugin = {
|
||||||
load: (danmuku: unknown) => Promise<unknown> | unknown;
|
load: (danmuku: unknown) => Promise<unknown> | unknown;
|
||||||
show: () => unknown;
|
show: () => unknown;
|
||||||
hide: () => unknown;
|
hide: () => unknown;
|
||||||
|
config: (option: { opacity?: number }) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
|
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 相同的批格式兜底 */
|
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
|
||||||
function sendProgressBeacon(input: ProgressInput) {
|
function sendProgressBeacon(input: ProgressInput) {
|
||||||
const url = "/api/trpc/media.playbackReport?batch=1";
|
const url = "/api/trpc/media.playbackReport?batch=1";
|
||||||
|
|
@ -121,14 +136,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const progressStartRef = useRef(0);
|
const progressStartRef = useRef(0);
|
||||||
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
||||||
const [localXml, setLocalXml] = useState("");
|
const [localXml, setLocalXml] = useState("");
|
||||||
|
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
|
||||||
|
const [matchXml, setMatchXml] = useState("");
|
||||||
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
|
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
|
||||||
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
|
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
const [playError, setPlayError] = useState(false);
|
const [playError, setPlayError] = useState(false);
|
||||||
|
const [matchOpen, setMatchOpen] = useState(false);
|
||||||
|
const [kwInput, setKwInput] = useState("");
|
||||||
|
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
|
||||||
|
|
||||||
// 进入/切换条目时恢复本地导入的弹幕
|
// 进入/切换条目时恢复本地导入的弹幕
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalXml(readLocalXml(mediaItemId));
|
setLocalXml(readLocalXml(mediaItemId));
|
||||||
|
setMatchXml("");
|
||||||
|
setFilterDraft(null);
|
||||||
|
setMatchOpen(false);
|
||||||
setPlayError(false);
|
setPlayError(false);
|
||||||
setEnded(false);
|
setEnded(false);
|
||||||
}, [mediaItemId]);
|
}, [mediaItemId]);
|
||||||
|
|
@ -136,8 +159,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
|
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
|
||||||
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
|
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
|
||||||
const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
|
const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
|
||||||
|
const prefs = trpc.media.danmakuGetSettings.useQuery();
|
||||||
const report = trpc.media.playbackReport.useMutation();
|
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);
|
const reportRef = useRef(report);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reportRef.current = report;
|
reportRef.current = report;
|
||||||
|
|
@ -177,6 +203,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
setDanmakuDensity(prefs.density);
|
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(() => {
|
useEffect(() => {
|
||||||
if (progress.data) {
|
if (progress.data) {
|
||||||
progressStartRef.current = progress.data.positionMs;
|
progressStartRef.current = progress.data.positionMs;
|
||||||
|
|
@ -327,11 +370,21 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
}
|
}
|
||||||
}, [progress.data]);
|
}, [progress.data]);
|
||||||
|
|
||||||
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
|
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
||||||
return thinDanmaku(all, danmakuDensity);
|
raw.sort((a, b) => a.time - b.time);
|
||||||
}, [xmlSource, danmakuDensity]);
|
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;
|
const danmakuCount = items.length;
|
||||||
|
|
||||||
// 弹幕数据变化时交给官方插件渲染
|
// 弹幕数据变化时交给官方插件渲染
|
||||||
|
|
@ -358,19 +411,111 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const text = String(reader.result ?? "");
|
const text = String(reader.result ?? "");
|
||||||
|
importXml.mutate(
|
||||||
|
{ mediaItemId, xml: text, fileName: file.name },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
setLocalXml(text);
|
setLocalXml(text);
|
||||||
writeLocalXml(mediaItemId, text);
|
writeLocalXml(mediaItemId, text);
|
||||||
importMeta.mutate(
|
setMatchXml("");
|
||||||
{ mediaItemId, byteSize: file.size },
|
toast.success(t(locale, "watch.importSaved"));
|
||||||
{
|
},
|
||||||
onSuccess: () => toast.success(t(locale, "watch.importOk")),
|
|
||||||
onError: () => toast.error(t(locale, "watch.importFail")),
|
onError: () => toast.error(t(locale, "watch.importFail")),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
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(() => {
|
const replay = useCallback(() => {
|
||||||
|
|
@ -431,12 +576,35 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
|
||||||
|
const fetchMessage = openDanmaku.data?.message ?? "";
|
||||||
|
// 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除)
|
||||||
|
const showRematch =
|
||||||
|
danmakuOn &&
|
||||||
|
(isLocalSource ||
|
||||||
|
(openDanmaku.data?.source === "none" &&
|
||||||
|
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"))));
|
||||||
|
const candidates = matchMut.data?.candidates ?? [];
|
||||||
|
const mediaTitle = detail.data.title;
|
||||||
|
const sameNameEpisodes = new Set(
|
||||||
|
candidates
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.animeTitle != null &&
|
||||||
|
c.animeTitle.length > 0 &&
|
||||||
|
(mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)),
|
||||||
|
)
|
||||||
|
.map((c) => c.episodeId),
|
||||||
|
);
|
||||||
|
|
||||||
const sourceLabel = !danmakuOn
|
const sourceLabel = !danmakuOn
|
||||||
? localXml
|
? localXml || isLocalSource
|
||||||
? t(locale, "watch.danmakuDisabledHint")
|
? t(locale, "watch.danmakuDisabledHint")
|
||||||
: t(locale, "watch.danmakuOff")
|
: t(locale, "watch.danmakuOff")
|
||||||
: localXml
|
: isLocalSource
|
||||||
? t(locale, "watch.danmakuCount", { count: danmakuCount })
|
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
|
||||||
|
: matchXml
|
||||||
|
? t(locale, "watch.danmakuOpenNetwork")
|
||||||
: openDanmaku.data?.source === "none"
|
: openDanmaku.data?.source === "none"
|
||||||
? t(locale, "watch.noDanmakuImport")
|
? t(locale, "watch.noDanmakuImport")
|
||||||
: openDanmaku.data?.source === "cache"
|
: openDanmaku.data?.source === "cache"
|
||||||
|
|
@ -495,10 +663,162 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<span>{sourceLabel}</span>
|
<span>{sourceLabel}</span>
|
||||||
|
{isLocalSource && fetchMessage.includes("同名") && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
||||||
|
{t(locale, "watch.sidecarLinked")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showRematch && (
|
||||||
|
<Button size="sm" variant="outline" onClick={openRematch}>
|
||||||
|
{t(locale, "watch.rematch")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span>{t(locale, "watch.progressHint")}</span>
|
<span>{t(locale, "watch.progressHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-lg border border-border p-3">
|
||||||
|
<p className="u-label">{t(locale, "watch.filterTitle")}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, "danmaku.blockKeywords")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(activeFilters?.blockKeywords ?? []).map((kw) => (
|
||||||
|
<span
|
||||||
|
key={kw}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => removeBlockKeyword(kw)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
className="h-8 w-40 text-sm"
|
||||||
|
value={kwInput}
|
||||||
|
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
|
||||||
|
onChange={(e) => setKwInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addBlockKeyword();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={addBlockKeyword}>
|
||||||
|
{t(locale, "watch.blockKeywordAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, "danmaku.blockTypes")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["scroll", "danmaku.blockTypeScroll"],
|
||||||
|
["top", "danmaku.blockTypeTop"],
|
||||||
|
["bottom", "danmaku.blockTypeBottom"],
|
||||||
|
] as const
|
||||||
|
).map(([kind, labelKey]) => {
|
||||||
|
const blocked = activeFilters?.blockTypes.includes(kind) ?? false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||||
|
blocked
|
||||||
|
? "border-primary bg-primary/10 font-medium text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleBlockType(kind)}
|
||||||
|
>
|
||||||
|
{t(locale, labelKey)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{matchOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
{t(locale, "watch.matchCandidates")}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setMatchOpen(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{matchMut.data?.highConfidence && (
|
||||||
|
<p className="text-xs text-primary">
|
||||||
|
{t(locale, "watch.matchAutoBound")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{matchMut.isPending && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, "watch.loading")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{matchMut.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{t(locale, "watch.matchFailed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{matchMut.isSuccess && candidates.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, "watch.matchEmpty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{candidates.length > 0 && (
|
||||||
|
<ul className="max-h-80 space-y-2 overflow-y-auto">
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<li key={c.episodeId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-primary/5"
|
||||||
|
disabled={selectMut.isPending}
|
||||||
|
onClick={() => onSelectCandidate(c.episodeId)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{c.animeTitle ?? `#${c.episodeId}`}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{c.episodeTitle ??
|
||||||
|
t(locale, "watch.episodeN", { n: c.episodeId })}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
episodeId {c.episodeId}
|
||||||
|
</span>
|
||||||
|
{sameNameEpisodes.has(c.episodeId) && (
|
||||||
|
<span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
||||||
|
{t(locale, "watch.foundSameName")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{episodes.length > 0 && (
|
{episodes.length > 0 && (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export type DanmakuItem = {
|
||||||
time: number;
|
time: number;
|
||||||
text: string;
|
text: string;
|
||||||
/** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */
|
|
||||||
mode: 0 | 1 | 2;
|
mode: 0 | 1 | 2;
|
||||||
color?: string | undefined;
|
color?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
@ -19,31 +30,19 @@ function toHexColor(decimal: number): string | undefined {
|
||||||
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
|
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */
|
/** DanmakuComment(含颜色/模式)→ 插件条目 */
|
||||||
export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] {
|
||||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
return list.map((c) => ({
|
||||||
const nodes = Array.from(doc.querySelectorAll("d"));
|
time: c.time,
|
||||||
const out: DanmakuItem[] = [];
|
text: c.text,
|
||||||
for (const n of nodes) {
|
mode: toPluginMode(c.mode),
|
||||||
const p = n.getAttribute("p") ?? "";
|
color: toHexColor(c.color),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** density < 1 时按比例抽稀,避免同屏过密 */
|
/** density 0.1–1:按时间均匀抽稀 */
|
||||||
export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] {
|
export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] {
|
||||||
if (density >= 1) return items;
|
if (density >= 1) return list;
|
||||||
const keepEvery = Math.max(1, Math.round(1 / density));
|
const keepEvery = Math.max(1, Math.round(1 / density));
|
||||||
return items.filter((_, i) => i % keepEvery === 0);
|
return list.filter((_, i) => i % keepEvery === 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"docs:dev": "yarn workspace @app/docs dev",
|
"docs:dev": "yarn workspace @app/docs dev",
|
||||||
"docs:build": "yarn workspace @app/docs build",
|
"docs:build": "yarn workspace @app/docs build",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
|
"test": "turbo run test",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"clean": "turbo run clean",
|
"clean": "turbo run clean",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
hasOpenCredentials,
|
||||||
|
openApiBase,
|
||||||
|
openCommentXml,
|
||||||
|
openHeaders,
|
||||||
|
openMatch,
|
||||||
|
readOpenCredentials,
|
||||||
|
} from "./client.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readOpenCredentials", () => {
|
||||||
|
it("reads from injected env", () => {
|
||||||
|
expect(
|
||||||
|
readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id", OPEN_DANMAKU_APP_SECRET: "s" }),
|
||||||
|
).toEqual({
|
||||||
|
appId: "id",
|
||||||
|
appSecret: "s",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("returns null when missing", () => {
|
||||||
|
expect(readOpenCredentials({})).toBeNull();
|
||||||
|
expect(readOpenCredentials({ OPEN_DANMAKU_APP_ID: "id" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("defaults to process.env", () => {
|
||||||
|
vi.stubEnv("OPEN_DANMAKU_APP_ID", "pid");
|
||||||
|
vi.stubEnv("OPEN_DANMAKU_APP_SECRET", "ps");
|
||||||
|
expect(readOpenCredentials()).toEqual({ appId: "pid", appSecret: "ps" });
|
||||||
|
expect(hasOpenCredentials()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openApiBase", () => {
|
||||||
|
it("strips trailing slashes", () => {
|
||||||
|
vi.stubEnv("OPEN_DANMAKU_API_BASE", "https://example.test///");
|
||||||
|
expect(openApiBase()).toBe("https://example.test");
|
||||||
|
});
|
||||||
|
it("defaults to dandanplay api", () => {
|
||||||
|
vi.stubEnv("OPEN_DANMAKU_API_BASE", undefined);
|
||||||
|
expect(openApiBase()).toBe("https://api.dandanplay.net");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openHeaders", () => {
|
||||||
|
it("signs request when credentials present", () => {
|
||||||
|
const h = openHeaders("/api/v2/match", { appId: "id", appSecret: "s" });
|
||||||
|
expect(h["Content-Type"]).toBe("application/json");
|
||||||
|
expect(h["X-AppId"]).toBe("id");
|
||||||
|
expect(h["X-Timestamp"]).toMatch(/^\d+$/);
|
||||||
|
expect(h["X-Signature"]).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("only content-type without credentials", () => {
|
||||||
|
expect(openHeaders("/api/v2/match", null)).toEqual({ "Content-Type": "application/json" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubFetchJson(json: unknown) {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => json,
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("openCommentXml 请求契约", () => {
|
||||||
|
it("请求路径含 ?withRelated=true(关联弹幕计入)", async () => {
|
||||||
|
const fetchMock = stubFetchJson({ success: true, comments: [] });
|
||||||
|
await openCommentXml(555);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||||
|
expect(url).toBe("https://api.dandanplay.net/api/v2/comment/555?withRelated=true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comments 序列化为 xml,HTTP 失败返回 null", async () => {
|
||||||
|
const fetchMock = stubFetchJson({
|
||||||
|
success: true,
|
||||||
|
comments: [{ p: "12.5,1,0,0,0,0,,", m: "hi" }],
|
||||||
|
});
|
||||||
|
await expect(openCommentXml(555)).resolves.toContain("<d ");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||||
|
await expect(openCommentXml(555)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openMatch 请求契约", () => {
|
||||||
|
it("POST /api/v2/match,matchMode 随 hash 切换", async () => {
|
||||||
|
const fetchMock = stubFetchJson({ success: true, isMatched: true, matches: [] });
|
||||||
|
|
||||||
|
await openMatch("Show - 02", 100, "abc");
|
||||||
|
const [url1, init1] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(String(url1)).toBe("https://api.dandanplay.net/api/v2/match");
|
||||||
|
expect(init1?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(init1?.body))).toEqual({
|
||||||
|
fileName: "Show - 02",
|
||||||
|
fileHash: "abc",
|
||||||
|
fileSize: 100,
|
||||||
|
matchMode: "hashAndFileName",
|
||||||
|
});
|
||||||
|
|
||||||
|
await openMatch("Show - 02", 100, null);
|
||||||
|
const init2 = fetchMock.mock.calls[1]?.[1];
|
||||||
|
expect(JSON.parse(String(init2?.body))).toMatchObject({
|
||||||
|
fileHash: "",
|
||||||
|
matchMode: "fileNameOnly",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { parseMatchOutcome, type OpenMatchOutcome } from "./match.js";
|
||||||
|
import { generateOpenSignature } from "./signature.js";
|
||||||
|
import { openCommentsToXml } from "./xml.js";
|
||||||
|
|
||||||
|
export type OpenCredentials = { appId: string; appSecret: string };
|
||||||
|
|
||||||
|
export function openApiBase(): string {
|
||||||
|
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOpenCredentials(env = process.env): OpenCredentials | null {
|
||||||
|
const appId = env["OPEN_DANMAKU_APP_ID"];
|
||||||
|
const appSecret = env["OPEN_DANMAKU_APP_SECRET"];
|
||||||
|
if (!appId || !appSecret) return null;
|
||||||
|
return { appId, appSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOpenCredentials(): boolean {
|
||||||
|
return readOpenCredentials() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openHeaders(path: string, creds: OpenCredentials | null): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (!creds) return headers;
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
return {
|
||||||
|
...headers,
|
||||||
|
"X-AppId": creds.appId,
|
||||||
|
"X-Timestamp": String(timestamp),
|
||||||
|
"X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFetchJson(
|
||||||
|
path: string,
|
||||||
|
init?: { method?: "POST"; body?: string } | undefined,
|
||||||
|
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
|
||||||
|
const creds = readOpenCredentials();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${openApiBase()}${path}`, {
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
headers: openHeaders(path, creds),
|
||||||
|
...(init?.body !== undefined ? { body: init.body } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` };
|
||||||
|
const json: unknown = await res.json();
|
||||||
|
const base = json as {
|
||||||
|
success?: boolean;
|
||||||
|
errorCode?: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
errorDetail?: string | null;
|
||||||
|
};
|
||||||
|
if (base.success === false || (base.errorCode ?? 0) !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: res.status,
|
||||||
|
error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, json };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: null,
|
||||||
|
error: e instanceof Error ? e.message : "弹弹play API 网络错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMatch(
|
||||||
|
fileName: string,
|
||||||
|
fileSize: number,
|
||||||
|
fileHash: string | null,
|
||||||
|
): Promise<OpenMatchOutcome> {
|
||||||
|
const res = await openFetchJson("/api/v2/match", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName,
|
||||||
|
fileHash: fileHash ?? "",
|
||||||
|
fileSize,
|
||||||
|
matchMode: fileHash ? "hashAndFileName" : "fileNameOnly",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] };
|
||||||
|
return { ok: true, errorMessage: null, ...parseMatchOutcome(res.json) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCommentXml(episodeId: number): Promise<string | null> {
|
||||||
|
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = res.json as { comments?: Array<Record<string, unknown>> };
|
||||||
|
return openCommentsToXml(body.comments ?? []);
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.js";
|
||||||
|
|
||||||
|
describe("xml round-trip", () => {
|
||||||
|
it("parse then serialize preserves time/mode/text", () => {
|
||||||
|
const xml = openCommentsToXml([{ p: "1.5,1,25,16777215,0,0,u,1", m: "你好<hello>" }]);
|
||||||
|
expect(xml).toContain('p="1.5,1,25,16777215,0,0,u,1"');
|
||||||
|
const list = parseDanmakuXml(xml);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0]?.time).toBeCloseTo(1.5);
|
||||||
|
expect(list[0]?.mode).toBe(1);
|
||||||
|
expect(list[0]?.text).toBe("你好<hello>");
|
||||||
|
const again = parseDanmakuXml(serializeDanmakuXml(list));
|
||||||
|
expect(again[0]?.time).toBeCloseTo(1.5);
|
||||||
|
expect(again[0]?.mode).toBe(1);
|
||||||
|
expect(again[0]?.size).toBe(25);
|
||||||
|
expect(again[0]?.color).toBe(16777215);
|
||||||
|
expect(again[0]?.uid).toBe("u");
|
||||||
|
expect(again[0]?.rowId).toBe("1");
|
||||||
|
expect(again[0]?.text).toBe("你好<hello>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDanmakuXml tolerance", () => {
|
||||||
|
it("4-segment p falls back size/color/uid/rowId to defaults", () => {
|
||||||
|
const list = parseDanmakuXml('<?xml version="1.0"?><i><d p="12.5,1,0,0">x</d></i>');
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0]?.time).toBeCloseTo(12.5);
|
||||||
|
expect(list[0]?.mode).toBe(1);
|
||||||
|
expect(list[0]?.size).toBe(0);
|
||||||
|
expect(list[0]?.color).toBe(0);
|
||||||
|
expect(list[0]?.timestamp).toBe(0);
|
||||||
|
expect(list[0]?.pool).toBe(0);
|
||||||
|
expect(list[0]?.uid).toBe("");
|
||||||
|
expect(list[0]?.rowId).toBe("");
|
||||||
|
expect(list[0]?.text).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shorter p defaults size/color to 0 and mode to 1", () => {
|
||||||
|
const two = parseDanmakuXml('<?xml version="1.0"?><i><d p="1.5,1">y</d></i>');
|
||||||
|
expect(two[0]?.time).toBeCloseTo(1.5);
|
||||||
|
expect(two[0]?.mode).toBe(1);
|
||||||
|
expect(two[0]?.size).toBe(0);
|
||||||
|
expect(two[0]?.color).toBe(0);
|
||||||
|
expect(two[0]?.uid).toBe("");
|
||||||
|
expect(two[0]?.rowId).toBe("");
|
||||||
|
|
||||||
|
const one = parseDanmakuXml('<?xml version="1.0"?><i><d p="3">z</d></i>');
|
||||||
|
expect(one[0]?.time).toBe(3);
|
||||||
|
expect(one[0]?.mode).toBe(1);
|
||||||
|
expect(one[0]?.size).toBe(0);
|
||||||
|
expect(one[0]?.color).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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, ">")
|
||||||
|
.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(`<d p="time,mode,size,color,timestamp,pool,uid,rowid">text</d>`) */
|
||||||
|
export function openCommentsToXml(comments: OpenComment[]): string {
|
||||||
|
const nodes: string[] = [];
|
||||||
|
for (const c of comments) {
|
||||||
|
const comment = toComment(c);
|
||||||
|
if (comment) nodes.push(commentNode(comment));
|
||||||
|
}
|
||||||
|
return wrapXml(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentNode(c: DanmakuComment): string {
|
||||||
|
const p = `${c.time},${c.mode},${c.size},${c.color},${c.timestamp},${c.pool},${c.uid},${c.rowId}`;
|
||||||
|
return `<d p="${escapeXml(p)}">${escapeXml(c.text)}</d>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapXml(nodes: string[]): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDanmakuXml(xml: string): DanmakuComment[] {
|
||||||
|
const list: DanmakuComment[] = [];
|
||||||
|
for (const m of xml.matchAll(/<d p="([^"]*)">([\s\S]*?)<\/d>/g)) {
|
||||||
|
const p = m[1];
|
||||||
|
const body = m[2];
|
||||||
|
if (p === undefined || body === undefined) continue;
|
||||||
|
const comment = parseP(p);
|
||||||
|
comment.text = unescapeXml(body);
|
||||||
|
if (!Number.isFinite(comment.time)) continue;
|
||||||
|
list.push(comment);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整 8 段 `p` 序列化,保证与解析互为逆操作 */
|
||||||
|
export function serializeDanmakuXml(list: DanmakuComment[]): string {
|
||||||
|
const nodes: string[] = [];
|
||||||
|
for (const c of list) nodes.push(commentNode(c));
|
||||||
|
return wrapXml(nodes);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"paths": {}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { danmakuDocs, type DanmakuDocRow } from "@app/models";
|
||||||
|
import { db } from "@app/db";
|
||||||
|
|
||||||
|
export const danmakuDocsDao = {
|
||||||
|
async get(userId: string, mediaItemId: string, source: string): Promise<DanmakuDocRow | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(danmakuDocs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(danmakuDocs.userId, userId),
|
||||||
|
eq(danmakuDocs.mediaItemId, mediaItemId),
|
||||||
|
eq(danmakuDocs.source, source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
async upsert(data: {
|
||||||
|
userId: string;
|
||||||
|
mediaItemId: string;
|
||||||
|
source: string;
|
||||||
|
xml: string;
|
||||||
|
byteSize: number;
|
||||||
|
}): Promise<DanmakuDocRow> {
|
||||||
|
const existing = await danmakuDocsDao.get(data.userId, data.mediaItemId, data.source);
|
||||||
|
if (existing) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(danmakuDocs)
|
||||||
|
.set({ xml: data.xml, byteSize: data.byteSize, updatedAt: new Date() })
|
||||||
|
.where(eq(danmakuDocs.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("Failed to update danmaku doc");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const rows = await db.insert(danmakuDocs).values(data).returning();
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error("Failed to insert danmaku doc");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
async remove(userId: string, mediaItemId: string, source: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(danmakuDocs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(danmakuDocs.userId, userId),
|
||||||
|
eq(danmakuDocs.mediaItemId, mediaItemId),
|
||||||
|
eq(danmakuDocs.source, source),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { danmakuPrefs, type DanmakuPrefsRow } from "@app/models";
|
||||||
|
import { db } from "@app/db";
|
||||||
|
|
||||||
|
export const danmakuPrefsDao = {
|
||||||
|
async getByUser(userId: string): Promise<DanmakuPrefsRow | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(danmakuPrefs)
|
||||||
|
.where(eq(danmakuPrefs.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
async upsert(data: {
|
||||||
|
userId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
opacity: number;
|
||||||
|
density: number;
|
||||||
|
blockKeywords: string;
|
||||||
|
blockTypes: string;
|
||||||
|
}): Promise<DanmakuPrefsRow> {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(danmakuPrefs)
|
||||||
|
.where(eq(danmakuPrefs.userId, data.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(danmakuPrefs)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(danmakuPrefs.id, existing[0].id))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("Failed to update danmaku prefs");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const rows = await db.insert(danmakuPrefs).values(data).returning();
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error("Failed to insert danmaku prefs");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -11,3 +11,5 @@ export { mediaItemDao } from "./media-items.js";
|
||||||
export type { MediaItemRow } from "./media-items.js";
|
export type { MediaItemRow } from "./media-items.js";
|
||||||
export { playbackProgressDao } from "./playback-progress.js";
|
export { playbackProgressDao } from "./playback-progress.js";
|
||||||
export { danmakuCacheDao } from "./danmaku-cache.js";
|
export { danmakuCacheDao } from "./danmaku-cache.js";
|
||||||
|
export { danmakuPrefsDao } from "./danmaku-prefs.js";
|
||||||
|
export { danmakuDocsDao } from "./danmaku-docs.js";
|
||||||
|
|
|
||||||
|
|
@ -296,13 +296,18 @@ export const mediaItemDao = {
|
||||||
async setDanmakuMatch(
|
async setDanmakuMatch(
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { episodeId: number; matchedHash?: string | null },
|
data: {
|
||||||
|
episodeId: number;
|
||||||
|
matchedHash?: string | null;
|
||||||
|
source?: "auto" | "manual" | "none";
|
||||||
|
},
|
||||||
): Promise<MediaItemRow | null> {
|
): Promise<MediaItemRow | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(mediaItems)
|
.update(mediaItems)
|
||||||
.set({
|
.set({
|
||||||
dandanplayEpisodeId: data.episodeId,
|
dandanplayEpisodeId: data.episodeId,
|
||||||
matchedHash: data.matchedHash ?? null,
|
matchedHash: data.matchedHash ?? null,
|
||||||
|
danmakuMatchSource: data.source ?? "auto",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))
|
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,19 @@ export const en: Record<MessageKey, string> = {
|
||||||
"watch.playbackFinished": "Episode finished",
|
"watch.playbackFinished": "Episode finished",
|
||||||
"watch.danmakuDisabledHint":
|
"watch.danmakuDisabledHint":
|
||||||
"Danmaku is off — enable it via the button above or in danmaku settings",
|
"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.title": "Danmaku settings",
|
||||||
"danmaku.opacity": "Opacity",
|
"danmaku.opacity": "Opacity",
|
||||||
|
|
@ -211,9 +224,19 @@ export const en: Record<MessageKey, string> = {
|
||||||
"danmaku.opacityValue": "Opacity {value}%",
|
"danmaku.opacityValue": "Opacity {value}%",
|
||||||
"danmaku.densityValue": "Density {value}%",
|
"danmaku.densityValue": "Density {value}%",
|
||||||
"danmaku.save": "Save",
|
"danmaku.save": "Save",
|
||||||
"danmaku.saved": "Saved (local session)",
|
"danmaku.saved": "Saved",
|
||||||
|
"danmaku.saveFailed": "Save failed",
|
||||||
"danmaku.localImportHint":
|
"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.title": "Sign in",
|
||||||
"auth.login.heading": "Sign in",
|
"auth.login.heading": "Sign in",
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,19 @@ export const zhCN = {
|
||||||
"watch.replay": "重新播放",
|
"watch.replay": "重新播放",
|
||||||
"watch.playbackFinished": "本集已播放完毕",
|
"watch.playbackFinished": "本集已播放完毕",
|
||||||
"watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启",
|
"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.title": "弹幕设置",
|
||||||
"danmaku.opacity": "不透明度",
|
"danmaku.opacity": "不透明度",
|
||||||
|
|
@ -206,8 +219,17 @@ export const zhCN = {
|
||||||
"danmaku.opacityValue": "不透明度 {value}%",
|
"danmaku.opacityValue": "不透明度 {value}%",
|
||||||
"danmaku.densityValue": "密度 {value}%",
|
"danmaku.densityValue": "密度 {value}%",
|
||||||
"danmaku.save": "保存",
|
"danmaku.save": "保存",
|
||||||
"danmaku.saved": "已保存(本地会话)",
|
"danmaku.saved": "已保存",
|
||||||
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
|
"danmaku.saveFailed": "保存失败",
|
||||||
|
"danmaku.localImportHint":
|
||||||
|
"本地 XML 导入在播放页操作;此处保存服务端开关、显示参数与屏蔽规则。",
|
||||||
|
"danmaku.blockKeywords": "屏蔽词",
|
||||||
|
"danmaku.blockTypes": "类型屏蔽",
|
||||||
|
"danmaku.blockTypeScroll": "滚动",
|
||||||
|
"danmaku.blockTypeTop": "顶部",
|
||||||
|
"danmaku.blockTypeBottom": "底部",
|
||||||
|
"danmaku.openNetworkNotConfigured": "未配置开放弹幕网络服务端凭据,在线弹幕可能为空。",
|
||||||
|
"danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。",
|
||||||
|
|
||||||
"auth.login.title": "登录",
|
"auth.login.title": "登录",
|
||||||
"auth.login.heading": "登录",
|
"auth.login.heading": "登录",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -13,3 +13,7 @@ export { playbackProgress } from "./playback-progress.js";
|
||||||
export type { PlaybackProgress } from "./playback-progress.js";
|
export type { PlaybackProgress } from "./playback-progress.js";
|
||||||
export { danmakuCache } from "./danmaku-cache.js";
|
export { danmakuCache } from "./danmaku-cache.js";
|
||||||
export type { DanmakuCacheRow } 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";
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export const mediaItems = sqliteTable(
|
||||||
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
|
/** 弹弹play 弹幕库编号(文件识别成功后落库,弹幕拉取直达) */
|
||||||
dandanplayEpisodeId: integer("dandanplay_episode_id"),
|
dandanplayEpisodeId: integer("dandanplay_episode_id"),
|
||||||
matchedHash: text("matched_hash"),
|
matchedHash: text("matched_hash"),
|
||||||
|
/** auto=高置信自动 | manual=手选 | none */
|
||||||
|
danmakuMatchSource: text("danmaku_match_source").notNull().default("none"),
|
||||||
scannedAt: integer("scanned_at", { mode: "timestamp" })
|
scannedAt: integer("scanned_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(unixepoch())`),
|
.default(sql`(unixepoch())`),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"dev": "tsx watch src/server.ts"
|
"dev": "tsx watch src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@app/danmaku": "workspace:*",
|
||||||
"@app/dao": "workspace:*",
|
"@app/dao": "workspace:*",
|
||||||
"@app/db": "workspace:*",
|
"@app/db": "workspace:*",
|
||||||
"@app/models": "workspace:*",
|
"@app/models": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
scrapeSchemas,
|
scrapeSchemas,
|
||||||
} from "@app/types";
|
} from "@app/types";
|
||||||
import { t } from "../context.js";
|
import { t } from "../context.js";
|
||||||
import { danmakuService, hasOpenCredentials } from "../services/danmaku.service.js";
|
import { danmakuService } from "../services/danmaku.service.js";
|
||||||
import { libraryService } from "../services/library.service.js";
|
import { libraryService } from "../services/library.service.js";
|
||||||
import { mountService } from "../services/mount.service.js";
|
import { mountService } from "../services/mount.service.js";
|
||||||
import { playbackService } from "../services/playback.service.js";
|
import { playbackService } from "../services/playback.service.js";
|
||||||
|
|
@ -127,18 +127,25 @@ export const mediaRouter = t.router({
|
||||||
danmakuFetch: protectedProcedure
|
danmakuFetch: protectedProcedure
|
||||||
.input(danmakuSchemas.fetch)
|
.input(danmakuSchemas.fetch)
|
||||||
.query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)),
|
.query(({ ctx, input }) => danmakuService.fetch(ctx.userId, input.mediaItemId)),
|
||||||
danmakuImportMeta: protectedProcedure
|
danmakuMatch: protectedProcedure
|
||||||
.input(danmakuSchemas.importMeta)
|
.input(danmakuSchemas.match)
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
danmakuService.importMeta(ctx.userId, input.mediaItemId, input.byteSize),
|
danmakuService.matchCandidates(ctx.userId, input.mediaItemId),
|
||||||
),
|
),
|
||||||
danmakuGetSettings: protectedProcedure.input(danmakuSchemas.getSettings).query(() => ({
|
danmakuSelectMatch: protectedProcedure
|
||||||
enabled: true,
|
.input(danmakuSchemas.selectMatch)
|
||||||
opacity: 0.8,
|
.mutation(({ ctx, input }) =>
|
||||||
density: 1,
|
danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId),
|
||||||
openNetworkConfigured: hasOpenCredentials(),
|
),
|
||||||
})),
|
danmakuImport: protectedProcedure
|
||||||
|
.input(danmakuSchemas.import)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
danmakuService.importXml(ctx.userId, input.mediaItemId, input.xml),
|
||||||
|
),
|
||||||
|
danmakuGetSettings: protectedProcedure
|
||||||
|
.input(danmakuSchemas.getSettings)
|
||||||
|
.query(({ ctx }) => danmakuService.getPrefs(ctx.userId)),
|
||||||
danmakuSaveSettings: protectedProcedure
|
danmakuSaveSettings: protectedProcedure
|
||||||
.input(danmakuSchemas.settings)
|
.input(danmakuSchemas.settings)
|
||||||
.mutation(({ input }) => input),
|
.mutation(({ ctx, input }) => danmakuService.savePrefs(ctx.userId, input)),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,432 @@
|
||||||
import { createHash } from "node:crypto";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { describe, expect, it } from "vitest";
|
import { openCommentXml, openCommentsToXml, openMatch, hasOpenCredentials } from "@app/danmaku";
|
||||||
import {
|
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
|
||||||
generateOpenSignature,
|
import type {
|
||||||
headMd5,
|
DanmakuCacheRow,
|
||||||
matchFileName,
|
DanmakuDocRow,
|
||||||
openCommentsToXml,
|
DanmakuPrefsRow,
|
||||||
parseMatchOutcome,
|
MediaItem as MediaItemRow,
|
||||||
} from "./danmaku.service.js";
|
Mount,
|
||||||
|
} from "@app/models";
|
||||||
|
import { danmakuService } from "./danmaku.service.js";
|
||||||
|
import { createWebdav } from "./webdav-client.js";
|
||||||
|
|
||||||
describe("danmaku open network helpers", () => {
|
vi.mock("@app/dao", () => ({
|
||||||
it("headMd5 matches md5 of sample", () => {
|
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
|
||||||
const sample = Buffer.from("hello");
|
danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() },
|
||||||
expect(headMd5(sample)).toBe(createHash("md5").update(sample).digest("hex"));
|
danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() },
|
||||||
|
mediaItemDao: {
|
||||||
|
getByIdForUser: vi.fn(),
|
||||||
|
setDanmakuMatch: vi.fn(),
|
||||||
|
},
|
||||||
|
mountDao: { getByIdForUser: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./webdav-client.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./webdav-client.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createWebdav: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it("signature is base64(sha256(AppId + Timestamp + Path + AppSecret))", () => {
|
vi.mock("@app/danmaku", async (importOriginal) => {
|
||||||
const sig = generateOpenSignature("app", 1735660800, "/api/v2/comment/1", "sec");
|
const actual = await importOriginal<typeof import("@app/danmaku")>();
|
||||||
const expectSig = createHash("sha256")
|
return {
|
||||||
.update("app" + "1735660800" + "/api/v2/comment/1" + "sec")
|
...actual,
|
||||||
.digest("base64");
|
openMatch: vi.fn(),
|
||||||
expect(sig).toBe(expectSig);
|
openCommentXml: vi.fn(),
|
||||||
|
hasOpenCredentials: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it("comments p/m maps to bilibili xml", () => {
|
function mediaItem(over: Partial<MediaItemRow> = {}): MediaItemRow {
|
||||||
|
return {
|
||||||
|
id: "m1",
|
||||||
|
userId: "u1",
|
||||||
|
mountId: null,
|
||||||
|
path: "/anime/[Sub] Title - 01.mkv",
|
||||||
|
rawName: "[Sub] Title - 01.mkv",
|
||||||
|
title: "Title",
|
||||||
|
size: 1024,
|
||||||
|
mime: null,
|
||||||
|
bangumiId: null,
|
||||||
|
epNumber: 1,
|
||||||
|
scrapeStatus: "pending",
|
||||||
|
scrapedAt: null,
|
||||||
|
posterUrl: null,
|
||||||
|
dandanplayEpisodeId: null,
|
||||||
|
matchedHash: null,
|
||||||
|
danmakuMatchSource: "none",
|
||||||
|
scannedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docRow(over: Partial<DanmakuDocRow> = {}): DanmakuDocRow {
|
||||||
|
return {
|
||||||
|
id: "d1",
|
||||||
|
userId: "u1",
|
||||||
|
mediaItemId: "m1",
|
||||||
|
source: "local-xml",
|
||||||
|
xml: "<i>local</i>",
|
||||||
|
byteSize: 12,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheRow(over: Partial<DanmakuCacheRow> = {}): DanmakuCacheRow {
|
||||||
|
return {
|
||||||
|
id: "c1",
|
||||||
|
matchKey: "4242",
|
||||||
|
payload: "<i>cached</i>",
|
||||||
|
source: "open-network",
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefsRow(over: Partial<DanmakuPrefsRow> = {}): DanmakuPrefsRow {
|
||||||
|
return {
|
||||||
|
id: "p1",
|
||||||
|
userId: "u1",
|
||||||
|
enabled: true,
|
||||||
|
opacity: 80,
|
||||||
|
density: 100,
|
||||||
|
blockKeywords: "[]",
|
||||||
|
blockTypes: "[]",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRow(over: Partial<Mount> = {}): Mount {
|
||||||
|
return {
|
||||||
|
id: "mnt1",
|
||||||
|
userId: "u1",
|
||||||
|
name: "nas",
|
||||||
|
type: "webdav",
|
||||||
|
baseUrl: "https://dav.example.com",
|
||||||
|
username: null,
|
||||||
|
secretEnc: "",
|
||||||
|
rootPath: "/library",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeEntry = {
|
||||||
|
filename: string;
|
||||||
|
basename: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeWebdavClient(opts: {
|
||||||
|
entries?: FakeEntry[];
|
||||||
|
contents?: string;
|
||||||
|
listError?: Error;
|
||||||
|
readError?: Error;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
getDirectoryContents: vi.fn().mockImplementation(async () => {
|
||||||
|
if (opts.listError) throw opts.listError;
|
||||||
|
return opts.entries ?? [];
|
||||||
|
}),
|
||||||
|
getFileContents: vi.fn().mockImplementation(async () => {
|
||||||
|
if (opts.readError) throw opts.readError;
|
||||||
|
return Buffer.from(opts.contents ?? "", "utf8");
|
||||||
|
}),
|
||||||
|
createReadStream: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.importXml", () => {
|
||||||
|
it("校验媒体存在后落 danmakuDocs(source=local-xml),count 粗算 <d 个数", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
const xml =
|
||||||
|
'<?xml version="1.0"?><i><d p="1,1,0,0,0,0,,">a</d><d p="2,1,0,0,0,0,,">b</d></i>';
|
||||||
|
const r = await danmakuService.importXml("u1", "m1", xml);
|
||||||
|
expect(r).toEqual({ ok: true, count: 2 });
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
mediaItemId: "m1",
|
||||||
|
source: "local-xml",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体不存在抛 NOT_FOUND", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
await expect(danmakuService.importXml("u1", "gone", "<i><d /></i>")).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无 <d 节点抛 BAD_REQUEST", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
await expect(danmakuService.importXml("u1", "m1", "<html></html>")).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按 UTF-8 字节限 20MB:code unit 未超但字节超限拒绝", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
// 7_000_000 个汉字 = 7M code units(旧实现放行)但 21MB UTF-8
|
||||||
|
const huge = `<i><d>${"中".repeat(7_000_000)}</d></i>`;
|
||||||
|
expect(huge.length).toBeLessThanOrEqual(20_000_000);
|
||||||
|
await expect(danmakuService.importXml("u1", "m1", huge)).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "XML 过大",
|
||||||
|
});
|
||||||
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.fetch", () => {
|
||||||
|
it("优先 local-xml:返回该文档且不再调开放网络", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||||
|
expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||||
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体不存在返回 ok:false", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.fetch("u1", "gone");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已绑 episodeId 且缓存有效 → source:cache", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
|
||||||
|
mediaItem({ dandanplayEpisodeId: 77 }),
|
||||||
|
);
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(
|
||||||
|
cacheRow({ payload: "<i>cached</i>" }),
|
||||||
|
);
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "cache", xml: "<i>cached</i>" });
|
||||||
|
expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77");
|
||||||
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未配置开放凭据时明示缺配置", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
expect(r.message).toContain("OPEN_DANMAKU_APP_ID");
|
||||||
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("高置信自动绑(source=auto)后拉取,缓存键为 String(episodeId)", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
episodeId: 555,
|
||||||
|
animeId: 1,
|
||||||
|
animeTitle: "T",
|
||||||
|
episodeTitle: "E",
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
|
episodeId: 555,
|
||||||
|
matchedHash: null,
|
||||||
|
source: "auto",
|
||||||
|
});
|
||||||
|
expect(openCommentXml).toHaveBeenCalledWith(555);
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: "open-network", xml: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
expect(danmakuCacheDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
matchKey: "555",
|
||||||
|
payload: "<i>open</i>",
|
||||||
|
source: "open-network",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多候选低置信:不自动绑,提示手选", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(danmakuCacheDao.getValid).mockResolvedValue(null);
|
||||||
|
// 两条同分(score 1.0),lead 0 < 0.15 → highConfidence false
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{ episodeId: 11, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null },
|
||||||
|
{ episodeId: 22, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
expect(r.xml).toBe("");
|
||||||
|
expect(r.message).toContain("手选");
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).not.toHaveBeenCalled();
|
||||||
|
expect(openCommentXml).not.toHaveBeenCalled();
|
||||||
|
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.selectMatch", () => {
|
||||||
|
it("写 manual 源绑定后拉取,缓存键 String(episodeId)", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
|
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
||||||
|
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" });
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
|
||||||
|
episodeId: 4242,
|
||||||
|
matchedHash: "abc",
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
expect(danmakuCacheDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ matchKey: "4242", payload: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
expect(danmakuDocsDao.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: "open-network", xml: "<i>open</i>" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("成功后清除 local-xml,避免本地源粘滞", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
|
||||||
|
await danmakuService.selectMatch("u1", "m1", 4242);
|
||||||
|
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拉取失败仍保留 manual 绑定,返回 none", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(openCommentXml).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.selectMatch("u1", "m1", 4242);
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
expect(r.message).toContain("拉取失败");
|
||||||
|
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled();
|
||||||
|
expect(danmakuCacheDao.upsert).not.toHaveBeenCalled();
|
||||||
|
// 手选即离开本地源:即便拉取失败也清 local-xml
|
||||||
|
expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.matchCandidates", () => {
|
||||||
|
it("透传开放网络候选与 highConfidence", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{ episodeId: 9, animeId: 2, animeTitle: "A", episodeTitle: "E1", imageUrl: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await danmakuService.matchCandidates("u1", "m1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.highConfidence).toBe(true);
|
||||||
|
expect(r.candidates[0]?.episodeId).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体不存在 → matched:false", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
const r = await danmakuService.matchCandidates("u1", "gone");
|
||||||
|
expect(r).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService prefs", () => {
|
||||||
|
it("getPrefs 默认值 + 整数百分比转 0–1 小数", async () => {
|
||||||
|
vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
const r = await danmakuService.getPrefs("u1");
|
||||||
|
expect(r).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
density: 1,
|
||||||
|
blockKeywords: [],
|
||||||
|
blockTypes: [],
|
||||||
|
openNetworkConfigured: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savePrefs 0–1 小数写回整数百分比", async () => {
|
||||||
|
vi.mocked(danmakuPrefsDao.getByUser).mockResolvedValue(
|
||||||
|
prefsRow({ opacity: 75, density: 50, blockKeywords: '["x"]', blockTypes: '["top"]' }),
|
||||||
|
);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
|
const r = await danmakuService.savePrefs("u1", {
|
||||||
|
enabled: false,
|
||||||
|
opacity: 0.75,
|
||||||
|
density: 0.5,
|
||||||
|
blockKeywords: ["x"],
|
||||||
|
blockTypes: ["top"],
|
||||||
|
});
|
||||||
|
expect(danmakuPrefsDao.upsert).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
enabled: false,
|
||||||
|
opacity: 75,
|
||||||
|
density: 50,
|
||||||
|
blockKeywords: '["x"]',
|
||||||
|
blockTypes: '["top"]',
|
||||||
|
});
|
||||||
|
expect(r.opacity).toBe(0.75);
|
||||||
|
expect(r.density).toBe(0.5);
|
||||||
|
expect(r.blockTypes).toEqual(["top"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openCommentsToXml(@app/danmaku,p 序列化 8 段)", () => {
|
||||||
|
it("comments p/m maps to bilibili xml(8 段 p)", () => {
|
||||||
const xml = openCommentsToXml([
|
const xml = openCommentsToXml([
|
||||||
{ p: "12.5,1,16777215", m: "hello" },
|
{ p: "12.5,1,16777215", m: "hello" },
|
||||||
{ p: "3,5,16777215", m: "top & <b>" },
|
{ p: "3,5,16777215", m: "top & <b>" },
|
||||||
]);
|
]);
|
||||||
expect(xml).toContain('<d p="12.5,1,0,0">hello</d>');
|
expect(xml).toContain('<d p="12.5,1,16777215,0,0,0,,">hello</d>');
|
||||||
expect(xml).toContain('<d p="3,5,0,0">top & <b></d>');
|
expect(xml).toContain('<d p="3,5,16777215,0,0,0,,">top & <b></d>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("comments with explicit fields also work", () => {
|
it("comments with explicit fields also work", () => {
|
||||||
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
|
const xml = openCommentsToXml([{ time: 1.25, mode: 4, text: "x" }]);
|
||||||
expect(xml).toContain('<d p="1.25,4,0,0">x</d>');
|
expect(xml).toContain('<d p="1.25,4,0,0,0,0,,">x</d>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips invalid comments", () => {
|
it("skips invalid comments", () => {
|
||||||
|
|
@ -46,53 +439,212 @@ describe("danmaku open network helpers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("matchFileName", () => {
|
describe("danmakuService.ensureSidecarXml", () => {
|
||||||
it("去文件夹路径与扩展名(官方 match 要求)", () => {
|
const sidecarXml = '<i><d p="1,1,0,0,0,0,,">hi</d></i>';
|
||||||
expect(matchFileName("/anime/[Sub] Title - 01 [1080p].mkv")).toBe(
|
|
||||||
"[Sub] Title - 01 [1080p]",
|
function setupMounted(over: Partial<MediaItemRow> = {}) {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
|
||||||
|
mediaItem({ mountId: "mnt1", ...over }),
|
||||||
);
|
);
|
||||||
});
|
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
|
||||||
it("点开头的隐藏文件不算扩展名", () => {
|
}
|
||||||
expect(matchFileName(".hidden")).toBe(".hidden");
|
|
||||||
});
|
it("同目录同名 .xml 侧车:读全文落 local-xml,返回 true", async () => {
|
||||||
it("无扩展名原样保留", () => {
|
setupMounted({ path: "/anime/[Sub] Title - 01.mkv" });
|
||||||
expect(matchFileName("Movie")).toBe("Movie");
|
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"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseMatchOutcome", () => {
|
it("扩展名 .XML 大小写不敏感同样命中", async () => {
|
||||||
it("提取候选及其刮削元数据", () => {
|
setupMounted({ path: "/anime/Show - 02.mkv" });
|
||||||
const r = parseMatchOutcome({
|
vi.mocked(createWebdav).mockReturnValue(
|
||||||
isMatched: true,
|
fakeWebdavClient({
|
||||||
matches: [
|
entries: [
|
||||||
{
|
{
|
||||||
episodeId: 111,
|
filename: "/library/anime/SHOW - 02.XML",
|
||||||
animeId: 9,
|
basename: "SHOW - 02.XML",
|
||||||
animeTitle: "葬送的芙莉莲",
|
type: "file",
|
||||||
episodeTitle: "第1话 冒险终结之地",
|
size: sidecarXml.length,
|
||||||
imageUrl: "http://img/1.jpg",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
contents: sidecarXml,
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
expect(r.isMatched).toBe(true);
|
|
||||||
expect(r.candidates[0]?.episodeId).toBe(111);
|
it("仅同 basename 的 .xml 命中:别的名/别的扩展/目录均跳过", async () => {
|
||||||
expect(r.candidates[0]?.animeId).toBe(9);
|
setupMounted({ path: "/anime/Show - 02.mkv" });
|
||||||
expect(r.candidates[0]?.animeTitle).toBe("葬送的芙莉莲");
|
const client = fakeWebdavClient({
|
||||||
expect(r.candidates[0]?.imageUrl).toBe("http://img/1.jpg");
|
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,
|
||||||
});
|
});
|
||||||
it("episodeId 缺失的条目丢弃,字符串 id 可解析", () => {
|
vi.mocked(createWebdav).mockReturnValue(client as never);
|
||||||
const r = parseMatchOutcome({
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
isMatched: false,
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
matches: [{ episodeId: "222" }, { animeTitle: "no id" }],
|
expect(client.getFileContents).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
expect(r.isMatched).toBe(false);
|
|
||||||
expect(r.candidates).toHaveLength(1);
|
it("侧车超过 20MB:不读文件直接跳过", async () => {
|
||||||
expect(r.candidates[0]?.episodeId).toBe(222);
|
setupMounted({ path: "/anime/Show - 02.mkv" });
|
||||||
expect(r.candidates[0]?.animeTitle).toBeNull();
|
const client = fakeWebdavClient({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
filename: "/library/anime/Show - 02.xml",
|
||||||
|
basename: "Show - 02.xml",
|
||||||
|
type: "file",
|
||||||
|
size: 20_000_001,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contents: sidecarXml,
|
||||||
});
|
});
|
||||||
it("空响应安全兜底", () => {
|
vi.mocked(createWebdav).mockReturnValue(client as never);
|
||||||
const r = parseMatchOutcome({});
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
expect(r.isMatched).toBe(false);
|
expect(client.getFileContents).not.toHaveBeenCalled();
|
||||||
expect(r.candidates).toEqual([]);
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非弹幕粗检(无 <d )跳过", async () => {
|
||||||
|
setupMounted({ path: "/anime/Show - 02.mkv" });
|
||||||
|
vi.mocked(createWebdav).mockReturnValue(
|
||||||
|
fakeWebdavClient({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
filename: "/library/anime/Show - 02.xml",
|
||||||
|
basename: "Show - 02.xml",
|
||||||
|
type: "file",
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contents: "<html>not danmaku</html>",
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("列目录/读取失败静默跳过不抛", async () => {
|
||||||
|
setupMounted({ path: "/anime/Show - 02.mkv" });
|
||||||
|
vi.mocked(createWebdav).mockReturnValue(
|
||||||
|
fakeWebdavClient({ listError: new Error("dav down") }) as never,
|
||||||
|
);
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
|
|
||||||
|
vi.mocked(createWebdav).mockReturnValue(
|
||||||
|
fakeWebdavClient({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
filename: "/library/anime/Show - 02.xml",
|
||||||
|
basename: "Show - 02.xml",
|
||||||
|
type: "file",
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
readError: new Error("boom"),
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
|
expect(danmakuDocsDao.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体无挂载/挂载失效:返回 false 且不建客户端", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: null }));
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
|
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: "mnt1" }));
|
||||||
|
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(null);
|
||||||
|
await expect(danmakuService.ensureSidecarXml("u1", "m1")).resolves.toBe(false);
|
||||||
|
expect(createWebdav).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuService.fetch 同名侧车", () => {
|
||||||
|
const sidecarXml = '<i><d p="1,1,0,0,0,0,,">side</d></i>';
|
||||||
|
|
||||||
|
it("无 local-xml 时自动关联侧车:返回 local-xml 且 message 含同名提示", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
|
||||||
|
mediaItem({ mountId: "mnt1", path: "/anime/Show - 01.mkv" }),
|
||||||
|
);
|
||||||
|
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
|
||||||
|
vi.mocked(createWebdav).mockReturnValue(
|
||||||
|
fakeWebdavClient({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
filename: "/library/anime/Show - 01.xml",
|
||||||
|
basename: "Show - 01.xml",
|
||||||
|
type: "file",
|
||||||
|
size: sidecarXml.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contents: sidecarXml,
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
vi.mocked(danmakuDocsDao.get)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(docRow({ xml: sidecarXml }));
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.source).toBe("local-xml");
|
||||||
|
expect(r.xml).toBe(sidecarXml);
|
||||||
|
expect(r.message).toContain("同名");
|
||||||
|
expect(openMatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("侧车未命中时继续走开放网络前置(缺配置时明示)", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ mountId: "mnt1" }));
|
||||||
|
vi.mocked(mountDao.getByIdForUser).mockResolvedValue(mountRow());
|
||||||
|
vi.mocked(createWebdav).mockReturnValue(fakeWebdavClient({ entries: [] }) as never);
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(null);
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(false);
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r.source).toBe("none");
|
||||||
|
expect(r.message).toContain("OPEN_DANMAKU_APP_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已有 local-xml 时不扫描侧车", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
|
||||||
|
const r = await danmakuService.fetch("u1", "m1");
|
||||||
|
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" });
|
||||||
|
expect(createWebdav).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,227 +1,36 @@
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { danmakuCacheDao, mediaItemDao, mountDao } from "@app/dao";
|
import { danmakuCacheDao, danmakuDocsDao, danmakuPrefsDao, mediaItemDao, mountDao } from "@app/dao";
|
||||||
import type { DanmakuFetchOutput } from "@app/types";
|
import {
|
||||||
|
HASH_HEAD_BYTES,
|
||||||
|
hasOpenCredentials,
|
||||||
|
headMd5,
|
||||||
|
matchFileName,
|
||||||
|
openCommentXml,
|
||||||
|
openMatch,
|
||||||
|
rankCandidates,
|
||||||
|
} from "@app/danmaku";
|
||||||
|
import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types";
|
||||||
import { decryptSecret } from "./secret.js";
|
import { decryptSecret } from "./secret.js";
|
||||||
import { createWebdav, joinWebdavPath } from "./webdav-client.js";
|
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
/** 官方文件识别 hash:文件前 16MB 的 MD5(doc.dandanplay.com/open) */
|
/** 同名侧车 XML 全文上限,与 importXml 一致 */
|
||||||
const HASH_HEAD_BYTES = 16 * 1024 * 1024;
|
const SIDECAR_MAX_BYTES = 20_000_000;
|
||||||
|
|
||||||
type OpenComment = {
|
/** 媒体路径 → 所在目录("" 表示挂载根)+ 去扩展名 basename */
|
||||||
p?: string;
|
function splitDirBase(path: string): { dir: string; base: string } {
|
||||||
m?: string;
|
const norm = path.replace(/\\/g, "/");
|
||||||
text?: string;
|
const idx = norm.lastIndexOf("/");
|
||||||
mode?: number;
|
const dir = idx >= 0 ? norm.slice(0, idx) : "";
|
||||||
time?: number;
|
const file = idx >= 0 ? norm.slice(idx + 1) : norm;
|
||||||
};
|
const dot = file.lastIndexOf(".");
|
||||||
|
return { dir, base: dot > 0 ? file.slice(0, dot) : file };
|
||||||
/** MatchResultV2 刮削子集(swagger:episodeId/animeId/animeTitle/episodeTitle/imageUrl) */
|
|
||||||
export type OpenMatchCandidate = {
|
|
||||||
episodeId: number;
|
|
||||||
animeId: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
episodeTitle: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OpenMatchOutcome = {
|
|
||||||
ok: boolean;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isMatched: boolean;
|
|
||||||
candidates: OpenMatchCandidate[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function openApiBase(): string {
|
|
||||||
return (process.env["OPEN_DANMAKU_API_BASE"] ?? "https://api.dandanplay.net").replace(
|
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCredentials(): { appId: string; appSecret: string } | null {
|
/** 同 basename + .xml,扩展名与 stem 均大小写不敏感 */
|
||||||
const appId = process.env["OPEN_DANMAKU_APP_ID"];
|
function isSidecarName(fileName: string, base: string): boolean {
|
||||||
const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"];
|
return fileName.toLowerCase() === `${base}.xml`.toLowerCase();
|
||||||
if (!appId || !appSecret) return null;
|
|
||||||
return { appId, appSecret };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 凭据是否已配置;未配置时开放网络整体不可用,UI 需明示而非静默降级 */
|
|
||||||
export function hasOpenCredentials(): boolean {
|
|
||||||
return openCredentials() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 官方签名:base64(sha256(AppId + Timestamp + Path + AppSecret)) */
|
|
||||||
export function generateOpenSignature(
|
|
||||||
appId: string,
|
|
||||||
timestamp: number,
|
|
||||||
path: string,
|
|
||||||
appSecret: string,
|
|
||||||
): string {
|
|
||||||
return createHash("sha256").update(`${appId}${timestamp}${path}${appSecret}`).digest("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文件头部 MD5(样本应已截到 16MB 内) */
|
|
||||||
export function headMd5(sample: Buffer): string {
|
|
||||||
return createHash("md5").update(sample).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 官方 match 要求 fileName 不含文件夹路径与扩展名;点开头的隐藏文件不算扩展名 */
|
|
||||||
export function matchFileName(path: string): string {
|
|
||||||
const base = path.split("/").pop() ?? path;
|
|
||||||
const dot = base.lastIndexOf(".");
|
|
||||||
return dot > 0 ? base.slice(0, dot) : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 开放网络 comments → Bilibili 式 XML(`<d p="time,mode,0,0">text</d>`) */
|
|
||||||
export function openCommentsToXml(comments: OpenComment[]): string {
|
|
||||||
const nodes: string[] = [];
|
|
||||||
for (const c of comments) {
|
|
||||||
let time = Number.NaN;
|
|
||||||
let mode = 1;
|
|
||||||
let text = "";
|
|
||||||
if (typeof c.p === "string") {
|
|
||||||
const [t, m] = c.p.split(",");
|
|
||||||
time = Number(t);
|
|
||||||
mode = Number(m) || 1;
|
|
||||||
text = c.m ?? "";
|
|
||||||
} else {
|
|
||||||
time = Number(c.time);
|
|
||||||
mode = Number(c.mode) || 1;
|
|
||||||
text = c.text ?? c.m ?? "";
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(time) || !text) continue;
|
|
||||||
nodes.push(`<d p="${time},${mode},0,0">${escapeXml(text)}</d>`);
|
|
||||||
}
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?><i>${nodes.join("")}</i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHeaders(path: string): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const creds = openCredentials();
|
|
||||||
if (!creds) return headers;
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
...headers,
|
|
||||||
"X-AppId": creds.appId,
|
|
||||||
"X-Timestamp": String(timestamp),
|
|
||||||
"X-Signature": generateOpenSignature(creds.appId, timestamp, path, creds.appSecret),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenApiBase = {
|
|
||||||
errorCode?: number;
|
|
||||||
success?: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
errorDetail?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 开放网络统一请求:拼 base + 鉴权头;HTTP 非 2xx / success=false / errorCode≠0 / 网络错误统一归到 ok:false */
|
|
||||||
export async function openFetchJson(
|
|
||||||
path: string,
|
|
||||||
init?: { method?: "POST"; body?: string } | undefined,
|
|
||||||
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${openApiBase()}${path}`, {
|
|
||||||
method: init?.method ?? "GET",
|
|
||||||
headers: openHeaders(path),
|
|
||||||
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, status: res.status, error: `弹弹play API HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
const json: unknown = await res.json();
|
|
||||||
const base = json as OpenApiBase;
|
|
||||||
if (base.success === false || (base.errorCode ?? 0) !== 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: res.status,
|
|
||||||
error: base.errorMessage || base.errorDetail || "弹弹play API 调用失败",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, json };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: null,
|
|
||||||
error: e instanceof Error ? e.message : "弹弹play API 网络错误",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawMatch = {
|
|
||||||
episodeId?: number | string;
|
|
||||||
animeId?: number | string;
|
|
||||||
animeTitle?: string | null;
|
|
||||||
episodeTitle?: string | null;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 解析 /api/v2/match 响应体为候选列表(纯函数便于单测);无 episodeId 的条目丢弃 */
|
|
||||||
export function parseMatchOutcome(json: unknown): {
|
|
||||||
isMatched: boolean;
|
|
||||||
candidates: OpenMatchCandidate[];
|
|
||||||
} {
|
|
||||||
const body = json as { isMatched?: boolean; matches?: RawMatch[] };
|
|
||||||
const candidates: OpenMatchCandidate[] = [];
|
|
||||||
for (const m of body.matches ?? []) {
|
|
||||||
const episodeId =
|
|
||||||
typeof m.episodeId === "number"
|
|
||||||
? m.episodeId
|
|
||||||
: Number.parseInt(String(m.episodeId ?? ""), 10);
|
|
||||||
if (!Number.isFinite(episodeId)) continue;
|
|
||||||
const animeId =
|
|
||||||
typeof m.animeId === "number"
|
|
||||||
? m.animeId
|
|
||||||
: Number.parseInt(String(m.animeId ?? ""), 10);
|
|
||||||
candidates.push({
|
|
||||||
episodeId,
|
|
||||||
animeId: Number.isFinite(animeId) ? animeId : null,
|
|
||||||
animeTitle: m.animeTitle ?? null,
|
|
||||||
episodeTitle: m.episodeTitle ?? null,
|
|
||||||
imageUrl: m.imageUrl ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { isMatched: body.isMatched === true, candidates };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v2/match;错误透出 errorMessage,不再吞成 null */
|
|
||||||
export async function openMatch(
|
|
||||||
fileName: string,
|
|
||||||
fileSize: number,
|
|
||||||
fileHash: string | null,
|
|
||||||
): Promise<OpenMatchOutcome> {
|
|
||||||
const res = await openFetchJson("/api/v2/match", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName,
|
|
||||||
fileHash: fileHash ?? "",
|
|
||||||
fileSize,
|
|
||||||
matchMode: fileHash ? "hashAndFileName" : "fileNameOnly",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, errorMessage: res.error, isMatched: false, candidates: [] };
|
|
||||||
}
|
|
||||||
const parsed = parseMatchOutcome(res.json);
|
|
||||||
return { ok: true, errorMessage: null, ...parsed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/v2/comment/{episodeId}?withRelated=true → Bilibili 式 XML */
|
|
||||||
export async function openCommentXml(episodeId: number): Promise<string | null> {
|
|
||||||
const res = await openFetchJson(`/api/v2/comment/${episodeId}?withRelated=true`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const body = res.json as { comments?: OpenComment[] };
|
|
||||||
return openCommentsToXml(body.comments ?? []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchKeyFor(path: string, size: number): string {
|
function matchKeyFor(path: string, size: number): string {
|
||||||
|
|
@ -258,12 +67,85 @@ export async function remoteFileHash(userId: string, mediaId: string): Promise<s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistOpenXml(userId: string, mediaItemId: string, xml: string): Promise<void> {
|
||||||
|
await danmakuDocsDao.upsert({
|
||||||
|
userId,
|
||||||
|
mediaItemId,
|
||||||
|
source: "open-network",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistOpenCache(episodeId: number, xml: string): Promise<void> {
|
||||||
|
await danmakuCacheDao.upsert({
|
||||||
|
matchKey: String(episodeId),
|
||||||
|
payload: xml,
|
||||||
|
source: "open-network",
|
||||||
|
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const danmakuService = {
|
export const danmakuService = {
|
||||||
|
/** 同目录同名 .xml 侧车 → 落 local-xml;失败/超限/非弹幕一律静默 false */
|
||||||
|
async ensureSidecarXml(userId: string, mediaItemId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item?.mountId) return false;
|
||||||
|
const mount = await mountDao.getByIdForUser(item.mountId, userId);
|
||||||
|
if (!mount) return false;
|
||||||
|
const { dir, base } = splitDirBase(item.path);
|
||||||
|
if (!base) return false;
|
||||||
|
const client = createWebdav({
|
||||||
|
baseUrl: mount.baseUrl,
|
||||||
|
username: mount.username ?? undefined,
|
||||||
|
password: mount.secretEnc ? decryptSecret(mount.secretEnc) : undefined,
|
||||||
|
});
|
||||||
|
const listAbs = joinWebdavPath(mount.rootPath || "/", dir || "/");
|
||||||
|
const entries = await listDirectory(client, listAbs);
|
||||||
|
const hit = entries.find(
|
||||||
|
(e: DirEntry) => e.type === "file" && isSidecarName(e.basename, base),
|
||||||
|
);
|
||||||
|
if (!hit || hit.size > SIDECAR_MAX_BYTES) return false;
|
||||||
|
const raw = (await client.getFileContents(hit.filename, {
|
||||||
|
format: "binary",
|
||||||
|
})) as Uint8Array;
|
||||||
|
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||||
|
if (buf.byteLength > SIDECAR_MAX_BYTES) return false;
|
||||||
|
const xml = buf.toString("utf8");
|
||||||
|
if (!xml.includes("<d ")) return false;
|
||||||
|
await danmakuDocsDao.upsert({
|
||||||
|
userId,
|
||||||
|
mediaItemId,
|
||||||
|
source: "local-xml",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
|
|
@ -273,18 +155,25 @@ export const danmakuService = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchKey = matchKeyFor(item.path, item.size);
|
const cacheKey =
|
||||||
const cached = await danmakuCacheDao.getValid(matchKey);
|
item.dandanplayEpisodeId != null
|
||||||
if (cached) {
|
? String(item.dandanplayEpisodeId)
|
||||||
return { ok: true, source: "cache", xml: cached.payload };
|
: matchKeyFor(item.path, item.size);
|
||||||
}
|
const cached = await danmakuCacheDao.getValid(cacheKey);
|
||||||
|
if (cached) return { ok: true, source: "cache", xml: cached.payload };
|
||||||
|
|
||||||
let episodeId = item.dandanplayEpisodeId ?? null;
|
let episodeId = item.dandanplayEpisodeId ?? null;
|
||||||
if (episodeId == null) {
|
if (episodeId == null) {
|
||||||
const hash = await remoteFileHash(userId, mediaItemId);
|
const hash = await remoteFileHash(userId, mediaItemId);
|
||||||
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
||||||
const best = outcome.candidates[0];
|
const ranked = rankCandidates(outcome.candidates, {
|
||||||
if (!outcome.ok || !outcome.isMatched || !best) {
|
hint: {
|
||||||
|
title: item.title,
|
||||||
|
rawName: item.rawName,
|
||||||
|
epNumber: item.epNumber ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source: "none",
|
source: "none",
|
||||||
|
|
@ -292,11 +181,23 @@ export const danmakuService = {
|
||||||
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
episodeId = best.episodeId;
|
// 仅高置信才自动绑;否则等手选(fetch 不落库)
|
||||||
// 识别身份落库:后续播放直接 comment,免重跑 16MB 读流 + match(元数据覆盖走库页识别入口)
|
if (!ranked.highConfidence) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: "none",
|
||||||
|
xml: "",
|
||||||
|
message: "匹配不明确,请在播放页手选弹幕集",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
episodeId = ranked.candidates[0]?.episodeId ?? null;
|
||||||
|
if (episodeId == null) {
|
||||||
|
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" };
|
||||||
|
}
|
||||||
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
episodeId,
|
episodeId,
|
||||||
matchedHash: hash,
|
matchedHash: hash,
|
||||||
|
source: "auto",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,22 +205,128 @@ export const danmakuService = {
|
||||||
if (xml === null) {
|
if (xml === null) {
|
||||||
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||||
}
|
}
|
||||||
await danmakuCacheDao.upsert({
|
await persistOpenXml(userId, mediaItemId, xml);
|
||||||
matchKey,
|
await persistOpenCache(episodeId, xml);
|
||||||
payload: xml,
|
|
||||||
source: "open-network",
|
|
||||||
expiresAt: new Date(Date.now() + CACHE_TTL_MS),
|
|
||||||
});
|
|
||||||
return { ok: true, source: "open-network", xml };
|
return { ok: true, source: "open-network", xml };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 本地 XML 由前端解析后直接喂播放器;此处仅校验媒体存在与体积。 */
|
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
|
||||||
async importMeta(userId: string, mediaItemId: string, byteSize: number): Promise<{ ok: true }> {
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
message: "媒体不存在",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasOpenCredentials()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
matched: false,
|
||||||
|
message: "未配置开放弹幕网络",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hash = await remoteFileHash(userId, mediaItemId);
|
||||||
|
const outcome = await openMatch(matchFileName(item.path), item.size, hash);
|
||||||
|
const ranked = rankCandidates(outcome.candidates, {
|
||||||
|
hint: {
|
||||||
|
title: item.title,
|
||||||
|
rawName: item.rawName,
|
||||||
|
epNumber: item.epNumber ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: outcome.ok,
|
||||||
|
matched: outcome.isMatched && ranked.candidates.length > 0,
|
||||||
|
message: outcome.errorMessage ?? undefined,
|
||||||
|
candidates: ranked.candidates,
|
||||||
|
highConfidence: ranked.highConfidence,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectMatch(
|
||||||
|
userId: string,
|
||||||
|
mediaItemId: string,
|
||||||
|
episodeId: number,
|
||||||
|
): Promise<DanmakuFetchOutput> {
|
||||||
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
|
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
|
||||||
|
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
|
||||||
|
episodeId,
|
||||||
|
matchedHash: item.matchedHash,
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
// 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源
|
||||||
|
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
|
||||||
|
const xml = await openCommentXml(episodeId);
|
||||||
|
if (xml === null) {
|
||||||
|
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" };
|
||||||
|
}
|
||||||
|
await persistOpenXml(userId, mediaItemId, xml);
|
||||||
|
await persistOpenCache(episodeId, xml);
|
||||||
|
return { ok: true, source: "open-network", xml };
|
||||||
|
},
|
||||||
|
|
||||||
|
async importXml(
|
||||||
|
userId: string,
|
||||||
|
mediaItemId: string,
|
||||||
|
xml: string,
|
||||||
|
): Promise<{ ok: true; count: number }> {
|
||||||
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
||||||
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
|
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
|
||||||
if (byteSize > 20_000_000) {
|
// 按 UTF-8 字节限流(Zod 同口径),而非 UTF-16 code unit
|
||||||
|
if (Buffer.byteLength(xml, "utf8") > 20_000_000) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" });
|
||||||
}
|
}
|
||||||
return { ok: true };
|
if (!xml.includes("<d ")) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "不是有效的弹幕 XML" });
|
||||||
|
}
|
||||||
|
await danmakuDocsDao.upsert({
|
||||||
|
userId,
|
||||||
|
mediaItemId,
|
||||||
|
source: "local-xml",
|
||||||
|
xml,
|
||||||
|
byteSize: Buffer.byteLength(xml, "utf8"),
|
||||||
|
});
|
||||||
|
return { ok: true, count: (xml.match(/<d /g) ?? []).length };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPrefs(userId: string): Promise<DanmakuSettingsOutput> {
|
||||||
|
const row = await danmakuPrefsDao.getByUser(userId);
|
||||||
|
return {
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
opacity: (row?.opacity ?? 80) / 100,
|
||||||
|
density: (row?.density ?? 100) / 100,
|
||||||
|
blockKeywords: row ? (JSON.parse(row.blockKeywords) as string[]) : [],
|
||||||
|
blockTypes: row
|
||||||
|
? (JSON.parse(row.blockTypes) as Array<"scroll" | "top" | "bottom">)
|
||||||
|
: [],
|
||||||
|
openNetworkConfigured: hasOpenCredentials(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePrefs(
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
enabled: boolean;
|
||||||
|
opacity: number;
|
||||||
|
density: number;
|
||||||
|
blockKeywords: string[];
|
||||||
|
blockTypes: Array<"scroll" | "top" | "bottom">;
|
||||||
|
},
|
||||||
|
): Promise<DanmakuSettingsOutput> {
|
||||||
|
await danmakuPrefsDao.upsert({
|
||||||
|
userId,
|
||||||
|
enabled: input.enabled,
|
||||||
|
opacity: Math.round(input.opacity * 100),
|
||||||
|
density: Math.round(input.density * 100),
|
||||||
|
blockKeywords: JSON.stringify(input.blockKeywords),
|
||||||
|
blockTypes: JSON.stringify(input.blockTypes),
|
||||||
|
});
|
||||||
|
return danmakuService.getPrefs(userId);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,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 {
|
import {
|
||||||
AUTO_BIND_THRESHOLD,
|
AUTO_BIND_THRESHOLD,
|
||||||
buildSearchQuery,
|
buildSearchQuery,
|
||||||
|
|
@ -6,6 +9,30 @@ import {
|
||||||
pickDandanEpisode,
|
pickDandanEpisode,
|
||||||
scoreHit,
|
scoreHit,
|
||||||
} from "./scrape.service.js";
|
} from "./scrape.service.js";
|
||||||
|
import { scrapeService } from "./scrape.service.js";
|
||||||
|
|
||||||
|
vi.mock("@app/dao", () => ({
|
||||||
|
mediaItemDao: {
|
||||||
|
getByIdForUser: vi.fn(),
|
||||||
|
updateScrape: vi.fn(),
|
||||||
|
listByPathPrefix: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/danmaku", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@app/danmaku")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
openMatch: vi.fn(),
|
||||||
|
openCommentXml: vi.fn(),
|
||||||
|
hasOpenCredentials: vi.fn(),
|
||||||
|
openFetchJson: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./danmaku.service.js", () => ({
|
||||||
|
remoteFileHash: vi.fn().mockResolvedValue("hash"),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("parseEpisodeFromFilename", () => {
|
describe("parseEpisodeFromFilename", () => {
|
||||||
it("S01E02", () => {
|
it("S01E02", () => {
|
||||||
|
|
@ -89,3 +116,123 @@ describe("scoreHit", () => {
|
||||||
expect(s).toBeLessThan(AUTO_BIND_THRESHOLD);
|
expect(s).toBeLessThan(AUTO_BIND_THRESHOLD);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mediaItem(over: Partial<MediaItem> = {}): MediaItem {
|
||||||
|
return {
|
||||||
|
id: "m1",
|
||||||
|
userId: "u1",
|
||||||
|
mountId: null,
|
||||||
|
path: "/anime/Show - 01.mkv",
|
||||||
|
rawName: "Show - 01.mkv",
|
||||||
|
title: "Show",
|
||||||
|
size: 1024,
|
||||||
|
mime: null,
|
||||||
|
bangumiId: null,
|
||||||
|
epNumber: 1,
|
||||||
|
scrapeStatus: "pending",
|
||||||
|
scrapedAt: null,
|
||||||
|
posterUrl: null,
|
||||||
|
dandanplayEpisodeId: null,
|
||||||
|
matchedHash: null,
|
||||||
|
danmakuMatchSource: "none",
|
||||||
|
scannedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scrapeService.matchDandanplay 绑定一致性", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(hasOpenCredentials).mockReturnValue(true);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
// score 0:无 animeTitle/episodeTitle → rankCandidates 会排到末尾
|
||||||
|
{ episodeId: 11, animeId: 1, animeTitle: null, episodeTitle: null, imageUrl: null },
|
||||||
|
// score 1.0 → ranked 第一名
|
||||||
|
{ episodeId: 22, animeId: 2, animeTitle: "T", episodeTitle: "E", imageUrl: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("绑定 ranked.candidates[0],且与返回 candidates[0] 一致", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
const r = await scrapeService.matchDandanplay("u1", "m1");
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
expect(r.candidates[0]?.episodeId).toBe(22);
|
||||||
|
expect(mediaItemDao.updateScrape).toHaveBeenCalledWith(
|
||||||
|
"m1",
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ dandanplayEpisodeId: 22 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("本地标题命中拉大差距 → 多候选仍高置信自动绑", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
episodeId: 11,
|
||||||
|
animeId: 1,
|
||||||
|
animeTitle: "Other",
|
||||||
|
episodeTitle: "X",
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
episodeId: 22,
|
||||||
|
animeId: 2,
|
||||||
|
animeTitle: "Show",
|
||||||
|
episodeTitle: "X",
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await scrapeService.matchDandanplay("u1", "m1");
|
||||||
|
expect(r.highConfidence).toBe(true);
|
||||||
|
expect(r.candidates[0]?.episodeId).toBe(22);
|
||||||
|
expect(mediaItemDao.updateScrape).toHaveBeenCalledWith(
|
||||||
|
"m1",
|
||||||
|
"u1",
|
||||||
|
expect.objectContaining({ dandanplayEpisodeId: 22 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("低置信多候选:不写库(不覆盖标题/海报),返回候选供手选", async () => {
|
||||||
|
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(
|
||||||
|
mediaItem({ title: "Keep Me", posterUrl: "https://img/keep.jpg" }),
|
||||||
|
);
|
||||||
|
vi.mocked(openMatch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
errorMessage: null,
|
||||||
|
isMatched: true,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
episodeId: 11,
|
||||||
|
animeId: 1,
|
||||||
|
animeTitle: "AAA",
|
||||||
|
episodeTitle: "X",
|
||||||
|
imageUrl: "a.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
episodeId: 22,
|
||||||
|
animeId: 2,
|
||||||
|
animeTitle: "BBB",
|
||||||
|
episodeTitle: "Y",
|
||||||
|
imageUrl: "b.jpg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = await scrapeService.matchDandanplay("u1", "m1");
|
||||||
|
expect(r.highConfidence).toBe(false);
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(r.candidates).toHaveLength(2);
|
||||||
|
expect(r.message).toContain("手选");
|
||||||
|
expect(mediaItemDao.updateScrape).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { mediaItemDao } from "@app/dao";
|
import { mediaItemDao } from "@app/dao";
|
||||||
import { parseEpisodeFromFilename, type BatchResult, type DanmakuMatchOutput } from "@app/types";
|
|
||||||
import {
|
import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
matchFileName,
|
matchFileName,
|
||||||
openFetchJson,
|
openFetchJson,
|
||||||
openMatch,
|
openMatch,
|
||||||
remoteFileHash,
|
rankCandidates,
|
||||||
} from "./danmaku.service.js";
|
} from "@app/danmaku";
|
||||||
|
import {
|
||||||
|
parseEpisodeFromFilename,
|
||||||
|
type BatchResult,
|
||||||
|
type DanmakuMatchOutput,
|
||||||
|
} from "@app/types";
|
||||||
|
import { remoteFileHash } from "./danmaku.service.js";
|
||||||
|
|
||||||
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
|
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
|
||||||
export { parseEpisodeFromFilename };
|
export { parseEpisodeFromFilename };
|
||||||
|
|
@ -199,6 +204,8 @@ export const scrapeService = {
|
||||||
matched: false,
|
matched: false,
|
||||||
message:
|
message:
|
||||||
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
|
"未配置开放弹幕网络:服务端需设置 OPEN_DANMAKU_APP_ID / OPEN_DANMAKU_APP_SECRET",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,10 +216,20 @@ export const scrapeService = {
|
||||||
ok: false,
|
ok: false,
|
||||||
matched: false,
|
matched: false,
|
||||||
message: outcome.errorMessage ?? "弹弹play 识别失败",
|
message: outcome.errorMessage ?? "弹弹play 识别失败",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const best = outcome.candidates[0];
|
const ranked = rankCandidates(outcome.candidates, {
|
||||||
if (best) {
|
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, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
dandanplayEpisodeId: best.episodeId,
|
dandanplayEpisodeId: best.episodeId,
|
||||||
matchedHash: hash,
|
matchedHash: hash,
|
||||||
|
|
@ -225,13 +242,17 @@ export const scrapeService = {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
result: {
|
candidates: ranked.candidates,
|
||||||
episodeId: best.episodeId,
|
highConfidence: ranked.highConfidence,
|
||||||
animeId: best.animeId,
|
};
|
||||||
animeTitle: best.animeTitle,
|
}
|
||||||
episodeTitle: best.episodeTitle,
|
if (best) {
|
||||||
imageUrl: best.imageUrl,
|
return {
|
||||||
},
|
ok: true,
|
||||||
|
matched: false,
|
||||||
|
message: "匹配不明确,请手选弹幕集",
|
||||||
|
candidates: ranked.candidates,
|
||||||
|
highConfidence: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,17 +272,26 @@ export const scrapeService = {
|
||||||
ok: true,
|
ok: true,
|
||||||
matched: true,
|
matched: true,
|
||||||
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
|
message: "已通过 Bangumi 绑定桥接到弹弹play 剧集",
|
||||||
result: {
|
candidates: [
|
||||||
|
{
|
||||||
episodeId: bridged.episodeId,
|
episodeId: bridged.episodeId,
|
||||||
animeId: null,
|
animeId: null,
|
||||||
animeTitle: row.title,
|
animeTitle: row.title,
|
||||||
episodeTitle: bridged.episodeTitle,
|
episodeTitle: bridged.episodeTitle,
|
||||||
imageUrl: row.posterUrl,
|
imageUrl: row.posterUrl,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
highConfidence: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, matched: false, message: "弹弹play 未识别到该文件" };
|
return {
|
||||||
|
ok: true,
|
||||||
|
matched: false,
|
||||||
|
message: "弹弹play 未识别到该文件",
|
||||||
|
candidates: [],
|
||||||
|
highConfidence: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */
|
/** 手动绑定:指定作品(可覆盖集数),弹弹play 式「用户选定对应关系」 */
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@app/danmaku": ["../danmaku/src/index.ts"],
|
||||||
"@app/types": ["../types/src/index.ts"],
|
"@app/types": ["../types/src/index.ts"],
|
||||||
"@app/dao": ["../dao/src/index.ts"],
|
"@app/dao": ["../dao/src/index.ts"],
|
||||||
"@app/db": ["../db/src/index.ts"],
|
"@app/db": ["../db/src/index.ts"],
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
|
"@app/danmaku",
|
||||||
"@app/dao",
|
"@app/dao",
|
||||||
"@app/db",
|
"@app/db",
|
||||||
"@app/models",
|
"@app/models",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("mountSchemas.create", () => {
|
||||||
it("拒绝缺 baseUrl", () => {
|
it("拒绝缺 baseUrl", () => {
|
||||||
|
|
@ -90,3 +96,83 @@ describe("海报墙组级操作 schema", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("danmakuSchemas.settings", () => {
|
||||||
|
it("缺省时填充默认值", () => {
|
||||||
|
const r = danmakuSchemas.settings.safeParse({});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
if (!r.success) return;
|
||||||
|
expect(r.data).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
density: 1,
|
||||||
|
blockKeywords: [],
|
||||||
|
blockTypes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("blockTypes 只接受 scroll|top|bottom", () => {
|
||||||
|
expect(
|
||||||
|
danmakuSchemas.settings.safeParse({ blockTypes: ["scroll", "top", "bottom"] }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(danmakuSchemas.settings.safeParse({ blockTypes: ["reverse"] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("opacity 越界拒绝", () => {
|
||||||
|
expect(danmakuSchemas.settings.safeParse({ opacity: 1.5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuSchemas.import", () => {
|
||||||
|
const validXml = `<?xml version="1.0"?><i><d p="0">hi</d></i>`;
|
||||||
|
|
||||||
|
it("接受含 <d 的弹幕 XML", () => {
|
||||||
|
const r = danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: validXml });
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
it("拒绝空 xml", () => {
|
||||||
|
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "" }).success).toBe(false);
|
||||||
|
});
|
||||||
|
it("拒绝不含 <d 的非弹幕内容", () => {
|
||||||
|
expect(
|
||||||
|
danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: "<html>no</html>" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("拒绝超过 20MB 的 xml", () => {
|
||||||
|
const huge = `<d ${"a".repeat(20_000_000)}`;
|
||||||
|
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("按 UTF-8 字节限 20MB:多字节字符 code unit 未超但字节超限拒绝", () => {
|
||||||
|
// 7_000_000 个汉字 = 7M code units(旧 .max(20_000_000) 放行)但 21MB UTF-8
|
||||||
|
const huge = `<d p="1,1,0,0,0,0,,">${"中".repeat(7_000_000)}</d>`;
|
||||||
|
expect(huge.length).toBeLessThanOrEqual(20_000_000);
|
||||||
|
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("多字节字符字节未超 20MB 时放行", () => {
|
||||||
|
// 6_000_000 个汉字 ≈ 18MB UTF-8,code units 仅 6M
|
||||||
|
const ok = `<d p="1,1,0,0,0,0,,">${"中".repeat(6_000_000)}</d>`;
|
||||||
|
expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: ok }).success).toBe(true);
|
||||||
|
});
|
||||||
|
it("fileName 超 255 拒绝", () => {
|
||||||
|
expect(
|
||||||
|
danmakuSchemas.import.safeParse({
|
||||||
|
mediaItemId: "m1",
|
||||||
|
xml: validXml,
|
||||||
|
fileName: "a".repeat(256),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("danmakuSchemas.selectMatch", () => {
|
||||||
|
it("要求正整数 episodeId", () => {
|
||||||
|
expect(
|
||||||
|
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 42 }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
danmakuSchemas.selectMatch.safeParse({ mediaItemId: "m1", episodeId: 0 }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,24 @@ import { z } from "zod";
|
||||||
|
|
||||||
const id = z.string().min(1);
|
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 = {
|
export const mountSchemas = {
|
||||||
create: z.object({
|
create: z.object({
|
||||||
name: z.string().min(1, "名称不能为空").max(100),
|
name: z.string().min(1, "名称不能为空").max(100),
|
||||||
|
|
@ -179,17 +197,29 @@ export const scrapeSchemas = {
|
||||||
export const danmakuSchemas = {
|
export const danmakuSchemas = {
|
||||||
/** 开放网络:按媒体文件取弹幕 */
|
/** 开放网络:按媒体文件取弹幕 */
|
||||||
fetch: z.object({ mediaItemId: id }),
|
fetch: z.object({ mediaItemId: id }),
|
||||||
/** 手动弹弹play 文件识别(match),命中后写刮削元数据 + 弹幕身份 */
|
/** 手动识别:返回候选列表,不自动绑 */
|
||||||
match: z.object({ mediaItemId: id }),
|
match: z.object({ mediaItemId: id }),
|
||||||
/** 本地 XML:仅登记导入元数据(正文在客户端喂给播放器) */
|
/** 手选绑定 episodeId */
|
||||||
importMeta: z.object({
|
selectMatch: z.object({
|
||||||
mediaItemId: id,
|
mediaItemId: id,
|
||||||
byteSize: z.number().min(0).max(20_000_000),
|
episodeId: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
/** 导入本地 XML 正文(服务端持久化);粗检含弹幕 <d 节点 */
|
||||||
|
import: z.object({
|
||||||
|
mediaItemId: id,
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine((v) => utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB")
|
||||||
|
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
|
||||||
|
fileName: z.string().max(255).optional(),
|
||||||
}),
|
}),
|
||||||
settings: z.object({
|
settings: z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
opacity: z.number().min(0).max(1).default(0.8),
|
opacity: z.number().min(0).max(1).default(0.8),
|
||||||
density: z.number().min(0.1).max(1).default(1),
|
density: z.number().min(0.1).max(1).default(1),
|
||||||
|
blockKeywords: z.array(z.string().max(50)).max(100).default([]),
|
||||||
|
blockTypes: z.array(z.enum(["scroll", "top", "bottom"])).default([]),
|
||||||
}),
|
}),
|
||||||
getSettings: z.object({}).optional(),
|
getSettings: z.object({}).optional(),
|
||||||
};
|
};
|
||||||
|
|
@ -214,10 +244,10 @@ export type BatchResult = {
|
||||||
};
|
};
|
||||||
export type DanmakuFetchOutput = {
|
export type DanmakuFetchOutput = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
source: "open-network" | "cache" | "none";
|
source: "open-network" | "cache" | "local-xml" | "none";
|
||||||
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
|
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
|
||||||
xml: string;
|
xml: string;
|
||||||
message?: string;
|
message?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 弹弹play match 命中的剧集(MatchResultV2 的刮削子集) */
|
/** 弹弹play match 命中的剧集(MatchResultV2 的刮削子集) */
|
||||||
|
|
@ -232,14 +262,17 @@ export type DandanMatchResult = {
|
||||||
export type DanmakuMatchOutput = {
|
export type DanmakuMatchOutput = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
matched: boolean;
|
matched: boolean;
|
||||||
message?: string;
|
message?: string | undefined;
|
||||||
result?: DandanMatchResult;
|
candidates: DandanMatchResult[];
|
||||||
|
highConfidence: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DanmakuSettingsOutput = {
|
export type DanmakuSettingsOutput = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
density: number;
|
density: number;
|
||||||
|
blockKeywords: string[];
|
||||||
|
blockTypes: Array<"scroll" | "top" | "bottom">;
|
||||||
/** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */
|
/** 服务端是否已配置开放弹幕网络凭据(未配置时弹幕获取恒为空) */
|
||||||
openNetworkConfigured: boolean;
|
openNetworkConfigured: boolean;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
"lint": {
|
"lint": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": ["^build"]
|
||||||
|
|
|
||||||
12
yarn.lock
12
yarn.lock
|
|
@ -221,6 +221,16 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
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":
|
"@app/dao@workspace:*, @app/dao@workspace:packages/dao":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/dao@workspace:packages/dao"
|
resolution: "@app/dao@workspace:packages/dao"
|
||||||
|
|
@ -304,6 +314,7 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/trpc@workspace:packages/trpc"
|
resolution: "@app/trpc@workspace:packages/trpc"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@app/danmaku": "workspace:*"
|
||||||
"@app/dao": "workspace:*"
|
"@app/dao": "workspace:*"
|
||||||
"@app/db": "workspace:*"
|
"@app/db": "workspace:*"
|
||||||
"@app/models": "workspace:*"
|
"@app/models": "workspace:*"
|
||||||
|
|
@ -366,6 +377,7 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@app/web@workspace:app/web"
|
resolution: "@app/web@workspace:app/web"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@app/danmaku": "workspace:*"
|
||||||
"@app/design-tokens": "workspace:*"
|
"@app/design-tokens": "workspace:*"
|
||||||
"@app/i18n": "workspace:*"
|
"@app/i18n": "workspace:*"
|
||||||
"@app/seo-geo": "workspace:*"
|
"@app/seo-geo": "workspace:*"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue