From e0ecf5b10624346fb1bf40072acd95c7a75c000d Mon Sep 17 00:00:00 2001 From: noelorin Date: Sun, 27 Sep 2026 15:41:20 +0800 Subject: [PATCH] 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. --- README.md | 21 +++++ .../components/pages/SettingsAdminBody.tsx | 5 +- app/web/src/components/pages/WatchBody.tsx | 55 ++++++++++-- packages/i18n/src/messages/en.ts | 6 ++ packages/i18n/src/messages/zh-CN.ts | 6 ++ packages/trpc/src/router/media.router.ts | 5 ++ packages/trpc/src/router/settings.router.ts | 8 +- .../trpc/src/services/danmaku.service.test.ts | 15 ++-- packages/trpc/src/services/danmaku.service.ts | 87 ++++++++++++++++--- packages/types/src/media-schemas.ts | 15 ++++ 10 files changed, 189 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 004e712..5ea44cd 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,24 @@ yarn dev:web # Astro :4321 `web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。 新增业务实体请按既有分层接线:`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 +# 然后走上面「首次运行配置」 +``` diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx index 321debf..cf8e663 100644 --- a/app/web/src/components/pages/SettingsAdminBody.tsx +++ b/app/web/src/components/pages/SettingsAdminBody.tsx @@ -11,7 +11,7 @@ function Inner(props: { locale: Locale }) { const isAdmin = me.data?.user?.role === "admin"; const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin }); 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 ddpLogout = trpc.settings.logoutDandanplay.useMutation(); const [ddpUser, setDdpUser] = useState(""); @@ -31,7 +31,7 @@ function Inner(props: { locale: Locale }) { if (me.isLoading) { return

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

; } - if (!isAdmin) { + if (!isAdmin && !me.data?.user) { return null; } @@ -110,6 +110,7 @@ function Inner(props: { locale: Locale }) {

{label("settings.ddpLogin")}

{label("settings.ddpLoginHint")}

+

{label("settings.sharedAccountHint")}

{ddpStatus.data?.loggedIn ? (
diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index c904542..c87bfc9 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -173,6 +173,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const prefs = trpc.media.danmakuGetSettings.useQuery(); const report = trpc.media.playbackReport.useMutation(); const importXml = trpc.media.danmakuImport.useMutation(); + const clearLocal = trpc.media.danmakuClearLocal.useMutation(); const matchMut = trpc.media.danmakuMatch.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 })}` : matchXml ? t(locale, "watch.danmakuOpenNetwork") - : openDanmaku.data?.source === "none" - ? t(locale, "watch.noDanmakuImport") - : openDanmaku.data?.source === "cache" - ? t(locale, "watch.danmakuCache") - : openDanmaku.data?.source === "open-network" - ? t(locale, "watch.danmakuOpenNetwork") - : t(locale, "watch.danmakuLoading"); + : openDanmaku.data?.reason === "need-login" + ? t(locale, "watch.needDdpLogin") + : openDanmaku.data?.reason === "no-app-cred" + ? t(locale, "watch.noAppCred") + : openDanmaku.data?.reason === "no-match" + ? t(locale, "watch.noDanmakuImport") + : 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 (
@@ -707,6 +714,40 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { {t(locale, "watch.rematch")} )} + {(openDanmaku.data?.reason === "need-login" || + openDanmaku.data?.reason === "no-app-cred") && ( + + )} + {isLocalSource && ( + + )} {t(locale, "watch.progressHint")}
diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index bf6086b..5cb5447 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -353,4 +353,10 @@ export const en: Record = { "settings.ddpLoginFailed": "Sign-in failed", "settings.ddpLoggedInAs": "Signed in as {name}", "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.", }; diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 322e1f8..8b11664 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -343,6 +343,12 @@ export const zhCN = { "settings.ddpLoginFailed": "登录失败", "settings.ddpLoggedInAs": "已登录:{name}", "watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)", + "watch.gotoSettings": "去设置", + "watch.noAppCred": "未配置开放弹幕网络", + "watch.clearLocalDanmaku": "清除本地弹幕", + "watch.clearLocalOk": "已清除本地弹幕", + "settings.nonAdminHint": "在线弹幕需管理员配置开放网络;普通账号可在此登录弹弹play 会话。", + "settings.sharedAccountHint": "弹弹play 账号为站点共用会话,用于拉取在线弹幕。", } as const; export type MessageKey = keyof typeof zhCN; diff --git a/packages/trpc/src/router/media.router.ts b/packages/trpc/src/router/media.router.ts index 37aca45..90cbbf5 100644 --- a/packages/trpc/src/router/media.router.ts +++ b/packages/trpc/src/router/media.router.ts @@ -143,6 +143,11 @@ export const mediaRouter = t.router({ danmakuSourceEpisodes: protectedProcedure .input(danmakuSchemas.sourceEpisodes) .query(({ input }) => danmakuService.sourceEpisodes(input.animeId)), + danmakuClearLocal: protectedProcedure + .input(danmakuSchemas.clearLocal) + .mutation(({ ctx, input }) => + danmakuService.clearLocalDanmaku(ctx.userId, input.mediaItemId), + ), danmakuImport: protectedProcedure .input(danmakuSchemas.import) .mutation(({ ctx, input }) => diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts index ff48324..a20e4a6 100644 --- a/packages/trpc/src/router/settings.router.ts +++ b/packages/trpc/src/router/settings.router.ts @@ -1,5 +1,5 @@ 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 { t } from "../context.js"; @@ -11,15 +11,15 @@ export const settingsRouter = t.router({ saveOpenNetwork: adminProcedure .input(settingsSchemas.saveOpenNetwork) .mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)), - loginDandanplay: adminProcedure + loginDandanplay: protectedProcedure .input(settingsSchemas.loginDandanplay) .mutation(({ ctx, input }) => settingsService.loginDandanplay(ctx.userId, input.username, input.password), ), - logoutDandanplay: adminProcedure + logoutDandanplay: protectedProcedure .input(settingsSchemas.logoutDandanplay) .mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)), - dandanplayAuthStatus: adminProcedure + dandanplayAuthStatus: protectedProcedure .input(settingsSchemas.logoutDandanplay) .query(() => settingsService.getDandanplayAuthStatus()), }); diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 4039a5d..b4dc535 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -13,6 +13,7 @@ import { createWebdav } from "./webdav-client.js"; vi.mock("./settings.service.js", () => ({ ensureOpenRuntime: vi.fn(async () => {}), + getDandanplayAuthStatus: vi.fn(async () => ({ loggedIn: true, screenName: "u" })), settingsService: {}, })); @@ -205,7 +206,7 @@ describe("danmakuService.fetch", () => { vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "local" })); const r = await danmakuService.fetch("u1", "m1"); - expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" }); + expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "local" }); expect(danmakuDocsDao.get).toHaveBeenCalledWith("u1", "m1", "local-xml"); expect(openMatch).not.toHaveBeenCalled(); expect(openCommentXml).not.toHaveBeenCalled(); @@ -228,7 +229,7 @@ describe("danmakuService.fetch", () => { cacheRow({ payload: "cached" }), ); const r = await danmakuService.fetch("u1", "m1"); - expect(r).toEqual({ ok: true, source: "cache", xml: "cached" }); + expect(r).toMatchObject({ ok: true, source: "cache", xml: "cached" }); expect(danmakuCacheDao.getValid).toHaveBeenCalledWith("77"); expect(openCommentXml).not.toHaveBeenCalled(); }); @@ -239,7 +240,7 @@ describe("danmakuService.fetch", () => { vi.mocked(hasOpenCredentials).mockReturnValue(false); const r = await danmakuService.fetch("u1", "m1"); expect(r.source).toBe("none"); - expect(r.message).toContain("账户设置"); + expect(r.message).toContain("设置 → 开放弹幕网络"); expect(openMatch).not.toHaveBeenCalled(); }); @@ -264,7 +265,7 @@ describe("danmakuService.fetch", () => { }); vi.mocked(openCommentXml).mockResolvedValue("open"); const r = await danmakuService.fetch("u1", "m1"); - expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" }); expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { episodeId: 555, matchedHash: null, @@ -313,7 +314,7 @@ describe("danmakuService.selectMatch", () => { vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem({ matchedHash: "abc" })); vi.mocked(openCommentXml).mockResolvedValue("open"); const r = await danmakuService.selectMatch("u1", "m1", 4242); - expect(r).toEqual({ ok: true, source: "open-network", xml: "open" }); + expect(r).toMatchObject({ ok: true, source: "open-network", xml: "open" }); expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalledWith("m1", "u1", { episodeId: 4242, matchedHash: "abc", @@ -642,14 +643,14 @@ describe("danmakuService.fetch 同名侧车", () => { vi.mocked(hasOpenCredentials).mockReturnValue(false); const r = await danmakuService.fetch("u1", "m1"); expect(r.source).toBe("none"); - expect(r.message).toContain("账户设置"); + expect(r.message).toContain("设置 → 开放弹幕网络"); }); it("已有 local-xml 时不扫描侧车", async () => { vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(danmakuDocsDao.get).mockResolvedValue(docRow({ xml: "local" })); const r = await danmakuService.fetch("u1", "m1"); - expect(r).toEqual({ ok: true, source: "local-xml", xml: "local" }); + expect(r).toMatchObject({ ok: true, source: "local-xml", xml: "local" }); expect(createWebdav).not.toHaveBeenCalled(); }); }); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index f932537..19e8faf 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -20,7 +20,7 @@ import type { DanmakuSourceSearchOutput, } from "@app/types"; 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"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -139,10 +139,20 @@ export const danmakuService = { async fetch(userId: string, mediaItemId: string): Promise { await ensureOpenRuntime(); 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"); - 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 返回 if (await danmakuService.ensureSidecarXml(userId, mediaItemId)) { @@ -151,6 +161,7 @@ export const danmakuService = { ok: true, source: "local-xml", xml: linked?.xml ?? "", + reason: "local-xml", message: "同名弹幕", }; } @@ -160,7 +171,8 @@ export const danmakuService = { ok: true, source: "none", xml: "", - message: "未配置开放弹幕网络:管理员请在「账户设置」页填写 AppId / AppSecret", + reason: "no-app-cred", + message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」填写 AppId / AppSecret", }; } @@ -169,7 +181,22 @@ export const danmakuService = { ? String(item.dandanplayEpisodeId) : matchKeyFor(item.path, item.size); 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; if (episodeId == null) { @@ -187,6 +214,7 @@ export const danmakuService = { ok: true, source: "none", xml: "", + reason: "no-match", message: outcome.errorMessage ?? "开放网络未匹配到弹幕", }; } @@ -196,12 +224,15 @@ export const danmakuService = { ok: true, source: "none", xml: "", + reason: "manual-select", message: "匹配不明确,请在播放页手选弹幕集", }; } episodeId = ranked.candidates[0]?.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, { episodeId, @@ -212,11 +243,17 @@ export const danmakuService = { const xml = await openCommentXml(episodeId); 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 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 { @@ -235,7 +272,7 @@ export const danmakuService = { return { ok: false, matched: false, - message: "未配置开放弹幕网络", + message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", candidates: [], highConfidence: false, }; @@ -265,7 +302,15 @@ export const danmakuService = { ): Promise { await ensureOpenRuntime(); 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, { episodeId, matchedHash: item.matchedHash, @@ -275,11 +320,25 @@ export const danmakuService = { await danmakuDocsDao.remove(userId, mediaItemId, "local-xml"); const xml = await openCommentXml(episodeId); 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 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( @@ -310,7 +369,7 @@ export const danmakuService = { async searchSource(keyword: string): Promise { await ensureOpenRuntime(); if (!hasOpenCredentials()) { - return { ok: false, message: "未配置开放弹幕网络", animes: [] }; + return { ok: false, message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animes: [] }; } const outcome = await openSearchEpisodes(keyword); if (!outcome.ok) { @@ -334,7 +393,7 @@ export const danmakuService = { if (!hasOpenCredentials()) { return { ok: false, - message: "未配置开放弹幕网络", + message: "未配置开放弹幕网络:请在「设置 → 开放弹幕网络」配置", animeId, animeTitle: null, episodes: [], diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index ec41f8d..96979d6 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -222,6 +222,8 @@ export const danmakuSchemas = { .refine((v) => v.includes("