feat: close product loop for online danmaku

DanmakuFetchOutput.reason (no-app-cred/need-login/no-match/...); watch page settings/clear-local CTAs; dandanplay login for all signed-in users; cache before login gate; README first-run checklist.
This commit is contained in:
noelorin 2026-09-27 15:41:20 +08:00
parent 4fe0f40ce4
commit e0ecf5b106
10 changed files with 189 additions and 34 deletions

View File

@ -50,3 +50,24 @@ yarn dev:web # Astro :4321
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。 `web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。 新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
## 首次运行配置(产品闭环)
1. 注册第一个账号(自动成为**管理员**)
2. **设置 → 开放弹幕网络**:填写弹弹play DevCenter 申请的 `AppId` / `AppSecret`([dev.dandanplay.com](https://dev.dandanplay.com))
3. **设置 → 弹弹play 账号**:登录弹弹play 账号(站点共用会话,用于拉在线弹幕)
4. 挂载 WebDAV → 扫描/入库 → 播放页自动匹配或手选作品/集数
未完成 2/3 时播放页会提示「去设置」;本地 XML 导入不受影响。
### Docker
```bash
docker build -t fanmu:local .
docker run -d -p 4330:4321 \
-e MEDIA_SECRET_KEY=<64-hex> \
-v $PWD/packages/db/local.db:/app/data/local.db \
fanmu:local
# 然后走上面「首次运行配置」
```

View File

@ -11,7 +11,7 @@ function Inner(props: { locale: Locale }) {
const isAdmin = me.data?.user?.role === "admin"; const isAdmin = me.data?.user?.role === "admin";
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin }); const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation(); const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, { enabled: isAdmin }); const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, { enabled: Boolean(me.data?.user) });
const ddpLogin = trpc.settings.loginDandanplay.useMutation(); const ddpLogin = trpc.settings.loginDandanplay.useMutation();
const ddpLogout = trpc.settings.logoutDandanplay.useMutation(); const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
const [ddpUser, setDdpUser] = useState(""); const [ddpUser, setDdpUser] = useState("");
@ -31,7 +31,7 @@ function Inner(props: { locale: Locale }) {
if (me.isLoading) { if (me.isLoading) {
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>; return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
} }
if (!isAdmin) { if (!isAdmin && !me.data?.user) {
return null; return null;
} }
@ -110,6 +110,7 @@ function Inner(props: { locale: Locale }) {
<div className="space-y-2 border-t border-border pt-3"> <div className="space-y-2 border-t border-border pt-3">
<p className="text-sm font-medium">{label("settings.ddpLogin")}</p> <p className="text-sm font-medium">{label("settings.ddpLogin")}</p>
<p className="text-xs text-muted-foreground">{label("settings.ddpLoginHint")}</p> <p className="text-xs text-muted-foreground">{label("settings.ddpLoginHint")}</p>
<p className="text-xs text-muted-foreground">{label("settings.sharedAccountHint")}</p>
{ddpStatus.data?.loggedIn ? ( {ddpStatus.data?.loggedIn ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm"> <span className="text-sm">

View File

@ -173,6 +173,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const prefs = trpc.media.danmakuGetSettings.useQuery(); const prefs = trpc.media.danmakuGetSettings.useQuery();
const report = trpc.media.playbackReport.useMutation(); const report = trpc.media.playbackReport.useMutation();
const importXml = trpc.media.danmakuImport.useMutation(); const importXml = trpc.media.danmakuImport.useMutation();
const clearLocal = trpc.media.danmakuClearLocal.useMutation();
const matchMut = trpc.media.danmakuMatch.useMutation(); const matchMut = trpc.media.danmakuMatch.useMutation();
const selectMut = trpc.media.danmakuSelectMatch.useMutation(); const selectMut = trpc.media.danmakuSelectMatch.useMutation();
// 手动指定弹幕源:作品 → 集数 // 手动指定弹幕源:作品 → 集数
@ -635,13 +636,19 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}` ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
: matchXml : matchXml
? t(locale, "watch.danmakuOpenNetwork") ? t(locale, "watch.danmakuOpenNetwork")
: openDanmaku.data?.source === "none" : openDanmaku.data?.reason === "need-login"
? t(locale, "watch.noDanmakuImport") ? t(locale, "watch.needDdpLogin")
: openDanmaku.data?.source === "cache" : openDanmaku.data?.reason === "no-app-cred"
? t(locale, "watch.danmakuCache") ? t(locale, "watch.noAppCred")
: openDanmaku.data?.source === "open-network" : openDanmaku.data?.reason === "no-match"
? t(locale, "watch.danmakuOpenNetwork") ? t(locale, "watch.noDanmakuImport")
: t(locale, "watch.danmakuLoading"); : openDanmaku.data?.source === "none"
? t(locale, "watch.noDanmakuImport")
: openDanmaku.data?.source === "cache"
? t(locale, "watch.danmakuCache")
: openDanmaku.data?.source === "open-network"
? t(locale, "watch.danmakuOpenNetwork")
: t(locale, "watch.danmakuLoading");
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -707,6 +714,40 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{t(locale, "watch.rematch")} {t(locale, "watch.rematch")}
</Button> </Button>
)} )}
{(openDanmaku.data?.reason === "need-login" ||
openDanmaku.data?.reason === "no-app-cred") && (
<Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/settings`)}
>
{t(locale, "watch.gotoSettings")}
</Button>
)}
{isLocalSource && (
<Button
size="sm"
variant="ghost"
disabled={clearLocal.isPending}
onClick={() =>
clearLocal.mutate(
{ mediaItemId },
{
onSuccess: () => {
setLocalXml("");
clearLocalXml(mediaItemId);
void openDanmaku.refetch();
toast.success(
t(locale, "watch.clearLocalOk"),
);
},
},
)
}
>
{t(locale, "watch.clearLocalDanmaku")}
</Button>
)}
</span> </span>
<span>{t(locale, "watch.progressHint")}</span> <span>{t(locale, "watch.progressHint")}</span>
</div> </div>

View File

@ -353,4 +353,10 @@ export const en: Record<MessageKey, string> = {
"settings.ddpLoginFailed": "Sign-in failed", "settings.ddpLoginFailed": "Sign-in failed",
"settings.ddpLoggedInAs": "Signed in as {name}", "settings.ddpLoggedInAs": "Signed in as {name}",
"watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku", "watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku",
"watch.gotoSettings": "Open settings",
"watch.noAppCred": "Open danmaku network not configured",
"watch.clearLocalDanmaku": "Clear local danmaku",
"watch.clearLocalOk": "Local danmaku cleared",
"settings.nonAdminHint": "Admin configures the open network; any signed-in user can bind the shared DanDanPlay session here.",
"settings.sharedAccountHint": "The DanDanPlay account is a site-wide session used to fetch online danmaku.",
}; };

View File

@ -343,6 +343,12 @@ export const zhCN = {
"settings.ddpLoginFailed": "登录失败", "settings.ddpLoginFailed": "登录失败",
"settings.ddpLoggedInAs": "已登录:{name}", "settings.ddpLoggedInAs": "已登录:{name}",
"watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)", "watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)",
"watch.gotoSettings": "去设置",
"watch.noAppCred": "未配置开放弹幕网络",
"watch.clearLocalDanmaku": "清除本地弹幕",
"watch.clearLocalOk": "已清除本地弹幕",
"settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。",
"settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。",
} as const; } as const;
export type MessageKey = keyof typeof zhCN; export type MessageKey = keyof typeof zhCN;

View File

@ -143,6 +143,11 @@ export const mediaRouter = t.router({
danmakuSourceEpisodes: protectedProcedure danmakuSourceEpisodes: protectedProcedure
.input(danmakuSchemas.sourceEpisodes) .input(danmakuSchemas.sourceEpisodes)
.query(({ input }) => danmakuService.sourceEpisodes(input.animeId)), .query(({ input }) => danmakuService.sourceEpisodes(input.animeId)),
danmakuClearLocal: protectedProcedure
.input(danmakuSchemas.clearLocal)
.mutation(({ ctx, input }) =>
danmakuService.clearLocalDanmaku(ctx.userId, input.mediaItemId),
),
danmakuImport: protectedProcedure danmakuImport: protectedProcedure
.input(danmakuSchemas.import) .input(danmakuSchemas.import)
.mutation(({ ctx, input }) => .mutation(({ ctx, input }) =>

View File

@ -1,5 +1,5 @@
import { settingsSchemas } from "@app/types"; import { settingsSchemas } from "@app/types";
import { adminProcedure } from "../services/procedure.js"; import { adminProcedure, protectedProcedure } from "../services/procedure.js";
import { settingsService } from "../services/settings.service.js"; import { settingsService } from "../services/settings.service.js";
import { t } from "../context.js"; import { t } from "../context.js";
@ -11,15 +11,15 @@ export const settingsRouter = t.router({
saveOpenNetwork: adminProcedure saveOpenNetwork: adminProcedure
.input(settingsSchemas.saveOpenNetwork) .input(settingsSchemas.saveOpenNetwork)
.mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)), .mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)),
loginDandanplay: adminProcedure loginDandanplay: protectedProcedure
.input(settingsSchemas.loginDandanplay) .input(settingsSchemas.loginDandanplay)
.mutation(({ ctx, input }) => .mutation(({ ctx, input }) =>
settingsService.loginDandanplay(ctx.userId, input.username, input.password), settingsService.loginDandanplay(ctx.userId, input.username, input.password),
), ),
logoutDandanplay: adminProcedure logoutDandanplay: protectedProcedure
.input(settingsSchemas.logoutDandanplay) .input(settingsSchemas.logoutDandanplay)
.mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)), .mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)),
dandanplayAuthStatus: adminProcedure dandanplayAuthStatus: protectedProcedure
.input(settingsSchemas.logoutDandanplay) .input(settingsSchemas.logoutDandanplay)
.query(() => settingsService.getDandanplayAuthStatus()), .query(() => settingsService.getDandanplayAuthStatus()),
}); });

View File

@ -13,6 +13,7 @@ import { createWebdav } from "./webdav-client.js";
vi.mock("./settings.service.js", () => ({ vi.mock("./settings.service.js", () => ({
ensureOpenRuntime: vi.fn(async () => {}), ensureOpenRuntime: vi.fn(async () => {}),
getDandanplayAuthStatus: vi.fn(async () => ({ loggedIn: true, screenName: "u" })),
settingsService: {}, settingsService: {},
})); }));
@ -205,7 +206,7 @@ describe("danmakuService.fetch", () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" })); vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" }); expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "<i>local</i>" });
expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml"); expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml");
expect(openMatch).not.toHaveBeenCalled(); expect(openMatch).not.toHaveBeenCalled();
expect(openCommentXml).not.toHaveBeenCalled(); expect(openCommentXml).not.toHaveBeenCalled();
@ -228,7 +229,7 @@ describe("danmakuService.fetch", () => {
cacheRow({ payload: "<i>cached</i>" }), cacheRow({ payload: "<i>cached</i>" }),
); );
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r).toEqual({ ok: true, source: "cache", xml: "<i>cached</i>" }); expect(r).toMatchObject({ ok: true, source: "cache", xml: "<i>cached</i>" });
expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77"); expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77");
expect(openCommentXml).not.toHaveBeenCalled(); expect(openCommentXml).not.toHaveBeenCalled();
}); });
@ -239,7 +240,7 @@ describe("danmakuService.fetch", () => {
vi.mocked(hasOpenCredentials).mockReturnValue(false); vi.mocked(hasOpenCredentials).mockReturnValue(false);
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r.source).toBe("none"); expect(r.source).toBe("none");
expect(r.message).toContain("账户设置"); expect(r.message).toContain("设置 → 开放弹幕网络");
expect(openMatch).not.toHaveBeenCalled(); expect(openMatch).not.toHaveBeenCalled();
}); });
@ -264,7 +265,7 @@ describe("danmakuService.fetch", () => {
}); });
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>"); vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" }); expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
episodeId: 555, episodeId: 555,
matchedHash: null, matchedHash: null,
@ -313,7 +314,7 @@ describe("danmakuService.selectMatch", () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" })); vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" }));
vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>"); vi.mocked(openCommentXml).mockResolvedValue("<i>open</i>");
const r = await danmakuService.selectMatch("u1", "m1", 4242); const r = await danmakuService.selectMatch("u1", "m1", 4242);
expect(r).toEqual({ ok: true, source: "open-network", xml: "<i>open</i>" }); expect(r).toMatchObject({ ok: true, source: "open-network", xml: "<i>open</i>" });
expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", {
episodeId: 4242, episodeId: 4242,
matchedHash: "abc", matchedHash: "abc",
@ -642,14 +643,14 @@ describe("danmakuService.fetch 同名侧车", () => {
vi.mocked(hasOpenCredentials).mockReturnValue(false); vi.mocked(hasOpenCredentials).mockReturnValue(false);
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r.source).toBe("none"); expect(r.source).toBe("none");
expect(r.message).toContain("账户设置"); expect(r.message).toContain("设置 → 开放弹幕网络");
}); });
it("已有 local-xml 时不扫描侧车", async () => { it("已有 local-xml 时不扫描侧车", async () => {
vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem());
vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" })); vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "<i>local</i>" }));
const r = await danmakuService.fetch("u1", "m1"); const r = await danmakuService.fetch("u1", "m1");
expect(r).toEqual({ ok: true, source: "local-xml", xml: "<i>local</i>" }); expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "<i>local</i>" });
expect(createWebdav).not.toHaveBeenCalled(); expect(createWebdav).not.toHaveBeenCalled();
}); });
}); });

View File

@ -20,7 +20,7 @@ import type {
DanmakuSourceSearchOutput, DanmakuSourceSearchOutput,
} from "@app/types"; } from "@app/types";
import { decryptSecret } from "./secret.js"; import { decryptSecret } from "./secret.js";
import { ensureOpenRuntime } from "./settings.service.js"; import { ensureOpenRuntime, getDandanplayAuthStatus } from "./settings.service.js";
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } 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;
@ -139,10 +139,20 @@ export const danmakuService = {
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> { async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
await ensureOpenRuntime(); await ensureOpenRuntime();
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: "",
reason: "not-found",
message: "媒体不存在",
};
}
const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml"); const local = await danmakuDocsDao.get(userId, mediaItemId, "local-xml");
if (local) return { ok: true, source: "local-xml", xml: local.xml }; if (local) {
return { ok: true, source: "local-xml", xml: local.xml, reason: "local-xml" };
}
// 同目录同名 .xml 侧车自动关联;命中即按 local-xml 返回 // 同目录同名 .xml 侧车自动关联;命中即按 local-xml 返回
if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) { if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) {
@ -151,6 +161,7 @@ export const danmakuService = {
ok: true, ok: true,
source: "local-xml", source: "local-xml",
xml: linked?.xml ?? "", xml: linked?.xml ?? "",
reason: "local-xml",
message: "同名弹幕", message: "同名弹幕",
}; };
} }
@ -160,7 +171,8 @@ export const danmakuService = {
ok: true, ok: true,
source: "none", source: "none",
xml: "", xml: "",
message: "未配置开放弹幕网络:管理员请在「账户设置」页填写 AppId / AppSecret", reason: "no-app-cred",
message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」填写 AppId / AppSecret",
}; };
} }
@ -169,7 +181,22 @@ export const danmakuService = {
? String(item.dandanplayEpisodeId) ? String(item.dandanplayEpisodeId)
: matchKeyFor(item.path, item.size); : matchKeyFor(item.path, item.size);
const cached = await danmakuCacheDao.getValid(cacheKey); const cached = await danmakuCacheDao.getValid(cacheKey);
if (cached) return { ok: true, source: "cache", xml: cached.payload }; if (cached) {
return { ok: true, source: "cache", xml: cached.payload, reason: "cache" };
}
{
const auth = await getDandanplayAuthStatus();
if (!auth.loggedIn) {
return {
ok: true,
source: "none",
xml: "",
reason: "need-login",
message: "获取在线弹幕需登录弹弹play 账号(设置页)",
};
}
}
let episodeId = item.dandanplayEpisodeId ?? null; let episodeId = item.dandanplayEpisodeId ?? null;
if (episodeId == null) { if (episodeId == null) {
@ -187,6 +214,7 @@ export const danmakuService = {
ok: true, ok: true,
source: "none", source: "none",
xml: "", xml: "",
reason: "no-match",
message: outcome.errorMessage ?? "开放网络未匹配到弹幕", message: outcome.errorMessage ?? "开放网络未匹配到弹幕",
}; };
} }
@ -196,12 +224,15 @@ export const danmakuService = {
ok: true, ok: true,
source: "none", source: "none",
xml: "", xml: "",
reason: "manual-select",
message: "匹配不明确,请在播放页手选弹幕集", message: "匹配不明确,请在播放页手选弹幕集",
}; };
} }
episodeId = ranked.candidates[0]?.episodeId ?? null; episodeId = ranked.candidates[0]?.episodeId ?? null;
if (episodeId == null) { if (episodeId == null) {
return { ok: true, source: "none", xml: "", message: "开放网络未匹配到弹幕" }; return { ok: true, source: "none", xml: "", reason: "no-match",
message: "开放网络未匹配到弹幕",
};
} }
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
episodeId, episodeId,
@ -212,11 +243,17 @@ export const danmakuService = {
const xml = await openCommentXml(episodeId); const xml = await openCommentXml(episodeId);
if (xml === null) { if (xml === null) {
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; return {
ok: true,
source: "none",
xml: "",
reason: "fetch-failed",
message: "弹幕库拉取失败",
};
} }
await persistOpenXml(userId, mediaItemId, xml); await persistOpenXml(userId, mediaItemId, xml);
await persistOpenCache(episodeId, xml); await persistOpenCache(episodeId, xml);
return { ok: true, source: "open-network", xml }; return { ok: true, source: "open-network", xml, reason: "ok" };
}, },
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> { async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
@ -235,7 +272,7 @@ export const danmakuService = {
return { return {
ok: false, ok: false,
matched: false, matched: false,
message: "未配置开放弹幕网络", message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
candidates: [], candidates: [],
highConfidence: false, highConfidence: false,
}; };
@ -265,7 +302,15 @@ export const danmakuService = {
): Promise<DanmakuFetchOutput> { ): Promise<DanmakuFetchOutput> {
await ensureOpenRuntime(); await ensureOpenRuntime();
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: "",
reason: "not-found",
message: "媒体不存在",
};
}
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
episodeId, episodeId,
matchedHash: item.matchedHash, matchedHash: item.matchedHash,
@ -275,11 +320,25 @@ export const danmakuService = {
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml"); await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
const xml = await openCommentXml(episodeId); const xml = await openCommentXml(episodeId);
if (xml === null) { if (xml === null) {
return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; return {
ok: true,
source: "none",
xml: "",
reason: "fetch-failed",
message: "弹幕库拉取失败",
};
} }
await persistOpenXml(userId, mediaItemId, xml); await persistOpenXml(userId, mediaItemId, xml);
await persistOpenCache(episodeId, xml); await persistOpenCache(episodeId, xml);
return { ok: true, source: "open-network", xml }; return { ok: true, source: "open-network", xml, reason: "ok" };
},
/** 清除本地导入/侧车弹幕,恢复开放网络优先 */
async clearLocalDanmaku(userId: string, mediaItemId: string): Promise<{ ok: true }> {
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" });
await danmakuDocsDao.remove(userId, mediaItemId, "local-xml");
return { ok: true };
}, },
async importXml( async importXml(
@ -310,7 +369,7 @@ export const danmakuService = {
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> { async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
await ensureOpenRuntime(); await ensureOpenRuntime();
if (!hasOpenCredentials()) { if (!hasOpenCredentials()) {
return { ok: false, message: "未配置开放弹幕网络", animes: [] }; return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] };
} }
const outcome = await openSearchEpisodes(keyword); const outcome = await openSearchEpisodes(keyword);
if (!outcome.ok) { if (!outcome.ok) {
@ -334,7 +393,7 @@ export const danmakuService = {
if (!hasOpenCredentials()) { if (!hasOpenCredentials()) {
return { return {
ok: false, ok: false,
message: "未配置开放弹幕网络", message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置",
animeId, animeId,
animeTitle: null, animeTitle: null,
episodes: [], episodes: [],

View File

@ -222,6 +222,8 @@ export const danmakuSchemas = {
.refine((v) => v.includes("<d "), "不是有效的弹幕 XML"), .refine((v) => v.includes("<d "), "不是有效的弹幕 XML"),
fileName: z.string().max(255).optional(), fileName: z.string().max(255).optional(),
}), }),
/** 清除该条目的本地弹幕文档 */
clearLocal: z.object({ mediaItemId: id }),
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),
@ -250,11 +252,24 @@ export type BatchResult = {
skipped: number; skipped: number;
message: string; message: string;
}; };
export type DanmakuFetchReason =
| "ok"
| "local-xml"
| "cache"
| "no-app-cred"
| "need-login"
| "no-match"
| "fetch-failed"
| "not-found"
| "manual-select";
export type DanmakuFetchOutput = { export type DanmakuFetchOutput = {
ok: boolean; ok: boolean;
source: "open-network" | "cache" | "local-xml" | "none"; source: "open-network" | "cache" | "local-xml" | "none";
/** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */ /** Bilibili/弹弹play 兼容 XML 字符串;无弹幕则为 "" */
xml: string; xml: string;
/** 机器可读原因,UI 据此给出路(配置/登录/手选) */
reason?: DanmakuFetchReason | undefined;
message?: string | undefined; message?: string | undefined;
}; };