diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx index d071576..321debf 100644 --- a/app/web/src/components/pages/SettingsAdminBody.tsx +++ b/app/web/src/components/pages/SettingsAdminBody.tsx @@ -11,6 +11,11 @@ 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 ddpLogin = trpc.settings.loginDandanplay.useMutation(); + const ddpLogout = trpc.settings.logoutDandanplay.useMutation(); + const [ddpUser, setDdpUser] = useState(""); + const [ddpPass, setDdpPass] = useState(""); const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); @@ -101,6 +106,85 @@ function Inner(props: { locale: Locale }) { {t(locale, "settings.save")} + +
+

{label("settings.ddpLogin")}

+

{label("settings.ddpLoginHint")}

+ {ddpStatus.data?.loggedIn ? ( +
+ + {t(locale, "settings.ddpLoggedInAs", { + name: ddpStatus.data.screenName ?? "", + })} + + +
+ ) : ( +
+ setDdpUser(e.target.value)} + autoComplete="username" + /> + setDdpPass(e.target.value)} + autoComplete="current-password" + /> + +
+ )} +
); } diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts index 77d2aa1..0dfe056 100644 --- a/packages/danmaku/src/client.ts +++ b/packages/danmaku/src/client.ts @@ -197,3 +197,62 @@ export async function openAnimeEpisodes(animeId: number): Promise { + return sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}; +} + +/** POST /api/v2/login — hash=md5(appId+password+timestamp+username+appSecret) */ +export async function loginAccount( + username: string, + password: string, +): Promise { + const creds = effectiveCredentials(); + if (!creds) { + return { ok: false, token: null, screenName: null, message: "未配置开放弹幕网络" }; + } + const timestamp = Math.floor(Date.now() / 1000); + const hashInput = `${creds.appId}${password}${timestamp}${username}${creds.appSecret}`; + const { createHash } = await import("node:crypto"); + const hash = createHash("md5").update(hashInput).digest("hex"); + const path = "/api/v2/login"; + const res = await openFetchJson(path, { + method: "POST", + body: JSON.stringify({ + userName: username, + password, + appId: creds.appId, + unixTimestamp: timestamp, + hash, + }), + }); + if (!res.ok) { + return { ok: false, token: null, screenName: null, message: res.error }; + } + const body = res.json as { + token?: string | null; + user?: { screenName?: string | null } | null; + }; + const token = body.token ?? null; + if (!token) { + return { ok: false, token: null, screenName: null, message: "登录响应缺少 token" }; + } + setSessionToken(token); + return { + ok: true, + token, + screenName: body.user?.screenName ?? username, + message: undefined, + }; +} diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index bf69249..0d1e9f3 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -15,6 +15,9 @@ export { openMatch, openSearchEpisodes, readOpenCredentials, + loginAccount, + setSessionToken, + getSessionToken, setOpenCredentials, effectiveCredentials, } from "./client.js"; diff --git a/packages/danmaku/src/types.ts b/packages/danmaku/src/types.ts index b058d1c..e07cd22 100644 --- a/packages/danmaku/src/types.ts +++ b/packages/danmaku/src/types.ts @@ -17,3 +17,10 @@ export type MatchCandidate = { episodeTitle: string | null; imageUrl: string | null; }; + +export type LoginResult = { + ok: boolean; + token: string | null; + screenName: string | null; + message?: string | undefined; +}; diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index f3a8f20..bf6086b 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -343,4 +343,14 @@ export const en: Record = { "settings.openDevCenter": "Open DevCenter", "settings.obtainSteps": "Steps: sign in / register at DevCenter → create an app → wait for review → copy AppId and AppSecret from the app detail", + "settings.ddpLogin": "DanDanPlay account", + "settings.ddpLoginHint": "After signing in, the server uses your session plus app credentials to fetch online danmaku (same idea as NipaPlay).", + "settings.ddpUsername": "Username", + "settings.ddpPassword": "Password", + "settings.ddpLoginBtn": "Sign in", + "settings.ddpLogoutBtn": "Sign out", + "settings.ddpLoginOk": "Signed in", + "settings.ddpLoginFailed": "Sign-in failed", + "settings.ddpLoggedInAs": "Signed in as {name}", + "watch.needDdpLogin": "Sign in to DanDanPlay in Settings to load online danmaku", }; diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index fdca021..322e1f8 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -333,6 +333,16 @@ export const zhCN = { "settings.openDevCenter": "打开开发者中心", "settings.obtainSteps": "步骤:登录/注册开发者中心 → 创建应用 → 等待审核 → 在应用详情复制 AppId 与 AppSecret", + "settings.ddpLogin": "弹弹play 账号", + "settings.ddpLoginHint": "登录弹弹play 账号后,系统用该账号会话配合应用凭证拉取在线弹幕(与 NipaPlay 同类做法)。", + "settings.ddpUsername": "账号", + "settings.ddpPassword": "密码", + "settings.ddpLoginBtn": "登录", + "settings.ddpLogoutBtn": "退出", + "settings.ddpLoginOk": "已登录", + "settings.ddpLoginFailed": "登录失败", + "settings.ddpLoggedInAs": "已登录:{name}", + "watch.needDdpLogin": "获取在线弹幕需登录弹弹play 账号(设置页)", } as const; export type MessageKey = keyof typeof zhCN; diff --git a/packages/models/src/app-settings.ts b/packages/models/src/app-settings.ts index a2fd894..0ec85f2 100644 --- a/packages/models/src/app-settings.ts +++ b/packages/models/src/app-settings.ts @@ -33,3 +33,6 @@ export type NewAppSettingRow = typeof appSettings.$inferInsert; /** 开放弹幕网络配置键 */ export const APP_SETTING_OPEN_DANMAKU_APP_ID = "open_danmaku_app_id"; export const APP_SETTING_OPEN_DANMAKU_APP_SECRET = "open_danmaku_app_secret"; + +export const APP_SETTING_DANDANPLAY_TOKEN = "dandanplay_token"; +export const APP_SETTING_DANDANPLAY_SCREEN_NAME = "dandanplay_screen_name"; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index ad533d1..9a2118a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -21,5 +21,7 @@ export { appSettings, APP_SETTING_OPEN_DANMAKU_APP_ID, APP_SETTING_OPEN_DANMAKU_APP_SECRET, + APP_SETTING_DANDANPLAY_TOKEN, + APP_SETTING_DANDANPLAY_SCREEN_NAME, } from "./app-settings.js"; export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js"; diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts index b9990b2..ff48324 100644 --- a/packages/trpc/src/router/settings.router.ts +++ b/packages/trpc/src/router/settings.router.ts @@ -11,4 +11,15 @@ export const settingsRouter = t.router({ saveOpenNetwork: adminProcedure .input(settingsSchemas.saveOpenNetwork) .mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)), + loginDandanplay: adminProcedure + .input(settingsSchemas.loginDandanplay) + .mutation(({ ctx, input }) => + settingsService.loginDandanplay(ctx.userId, input.username, input.password), + ), + logoutDandanplay: adminProcedure + .input(settingsSchemas.logoutDandanplay) + .mutation(({ ctx }) => settingsService.logoutDandanplay(ctx.userId)), + dandanplayAuthStatus: adminProcedure + .input(settingsSchemas.logoutDandanplay) + .query(() => settingsService.getDandanplayAuthStatus()), }); diff --git a/packages/trpc/src/services/settings.service.ts b/packages/trpc/src/services/settings.service.ts index b357990..e6bdad0 100644 --- a/packages/trpc/src/services/settings.service.ts +++ b/packages/trpc/src/services/settings.service.ts @@ -1,7 +1,17 @@ -import { APP_SETTING_OPEN_DANMAKU_APP_ID, APP_SETTING_OPEN_DANMAKU_APP_SECRET } from "@app/models"; +import { + APP_SETTING_DANDANPLAY_SCREEN_NAME, + APP_SETTING_DANDANPLAY_TOKEN, + APP_SETTING_OPEN_DANMAKU_APP_ID, + APP_SETTING_OPEN_DANMAKU_APP_SECRET, +} from "@app/models"; import { appSettingsDao } from "@app/dao"; import type { OpenNetworkSettingsOutput, SaveOpenNetworkSettingsInput } from "@app/types"; -import { setOpenCredentials, type OpenCredentials } from "@app/danmaku"; +import { + loginAccount, + setSessionToken, + setOpenCredentials, + type OpenCredentials, +} from "@app/danmaku"; function envCredentials(): OpenCredentials | null { const appId = process.env["OPEN_DANMAKU_APP_ID"]; @@ -71,6 +81,7 @@ export async function ensureOpenRuntime(): Promise { if (runtimeApplied) return; try { await applyRuntimeOpenCredentials(); + await applyRuntimeSession(); } catch { // 表未迁移或 DB 不可用时回退 env } @@ -82,4 +93,75 @@ export const settingsService = { saveOpenNetworkSettings, applyRuntimeOpenCredentials, ensureOpenRuntime, + loginDandanplay, + logoutDandanplay, + getDandanplayAuthStatus, + applyRuntimeSession, }; + +const APP_KEY_DDP_TOKEN = "dandanplay_token"; +const APP_KEY_DDP_NAME = "dandanplay_screen_name"; + +/** 登录弹弹play 账号,token 入库并注入运行时(供拉弹幕) */ +export async function loginDandanplay( + userId: string, + username: string, + password: string, +): Promise<{ ok: boolean; message?: string; screenName: string | null; loggedIn: boolean }> { + await ensureOpenRuntime(); + const result = await loginAccount(username, password); + if (!result.ok || !result.token) { + return { + ok: false, + message: result.message ?? "登录失败", + screenName: null, + loggedIn: false, + }; + } + await appSettingsDao.upsert({ + key: APP_SETTING_DANDANPLAY_TOKEN, + value: result.token, + updatedBy: userId, + }); + await appSettingsDao.upsert({ + key: APP_SETTING_DANDANPLAY_SCREEN_NAME, + value: result.screenName ?? username, + updatedBy: userId, + }); + return { + ok: true, + screenName: result.screenName ?? username, + loggedIn: true, + }; +} + +export async function logoutDandanplay(userId: string): Promise<{ ok: true }> { + await appSettingsDao.upsert({ key: APP_SETTING_DANDANPLAY_TOKEN, value: "", updatedBy: userId }); + await appSettingsDao.upsert({ + key: APP_SETTING_DANDANPLAY_SCREEN_NAME, + value: "", + updatedBy: userId, + }); + setSessionToken(null); + return { ok: true }; +} + +export async function getDandanplayAuthStatus(): Promise<{ + loggedIn: boolean; + screenName: string | null; +}> { + const nameRow = await appSettingsDao.get(APP_SETTING_DANDANPLAY_SCREEN_NAME); + const tokenRow = await appSettingsDao.get(APP_SETTING_DANDANPLAY_TOKEN); + const loggedIn = Boolean(tokenRow?.value); + return { loggedIn, screenName: loggedIn ? (nameRow?.value || null) : null }; +} + +/** 启动/保存后恢复会话 token */ +export async function applyRuntimeSession(): Promise { + try { + const tokenRow = await appSettingsDao.get(APP_SETTING_DANDANPLAY_TOKEN); + setSessionToken(tokenRow?.value || null); + } catch { + setSessionToken(null); + } +} diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 6a9da4a..c94dc64 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -147,6 +147,19 @@ export const settingsSchemas = { appSecret: z.string().max(500).default(""), }), getOpenNetwork: z.object({}).optional(), + /** 登录弹弹play 账号以获取在线弹幕 */ + loginDandanplay: z.object({ + username: z.string().min(1, "请输入账号").max(100), + password: z.string().min(1, "请输入密码").max(128), + }), + logoutDandanplay: z.object({}).optional(), +}; + +export type DandanplayLoginOutput = { + ok: boolean; + message?: string | undefined; + screenName: string | null; + loggedIn: boolean; }; /** 开放弹幕网络配置(管理员)。secret 明文仅供管理员读改。 */