feat(danmaku): login DanDanPlay account for online danmaku

Follow NipaPlay pattern: POST /api/v2/login (md5 hash) stores bearer token; comment/match send Authorization Bearer alongside AppId signature. Admin UI login form.
This commit is contained in:
noelorin 2026-09-27 15:14:13 +08:00
parent fd70f649ae
commit 4fe0f40ce4
11 changed files with 286 additions and 2 deletions

View File

@ -11,6 +11,11 @@ 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 ddpLogin = trpc.settings.loginDandanplay.useMutation();
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
const [ddpUser, setDdpUser] = useState("");
const [ddpPass, setDdpPass] = useState("");
const [appId, setAppId] = useState(""); const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState(""); const [appSecret, setAppSecret] = useState("");
@ -101,6 +106,85 @@ function Inner(props: { locale: Locale }) {
{t(locale, "settings.save")} {t(locale, "settings.save")}
</Button> </Button>
</div> </div>
<div className="space-y-2 border-t border-border pt-3">
<p className="text-sm font-medium">{label("settings.ddpLogin")}</p>
<p className="text-xs text-muted-foreground">{label("settings.ddpLoginHint")}</p>
{ddpStatus.data?.loggedIn ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm">
{t(locale, "settings.ddpLoggedInAs", {
name: ddpStatus.data.screenName ?? "",
})}
</span>
<Button
size="sm"
variant="outline"
disabled={ddpLogout.isPending}
onClick={() =>
ddpLogout.mutate(
{},
{
onSuccess: () => {
void ddpStatus.refetch();
toast.success(t(locale, "settings.ddpLoginOk"));
},
},
)
}
>
{label("settings.ddpLogoutBtn")}
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<Input
className="h-9 w-40"
value={ddpUser}
placeholder={label("settings.ddpUsername")}
onChange={(e) => setDdpUser(e.target.value)}
autoComplete="username"
/>
<Input
className="h-9 w-40"
type="password"
value={ddpPass}
placeholder={label("settings.ddpPassword")}
onChange={(e) => setDdpPass(e.target.value)}
autoComplete="current-password"
/>
<Button
size="sm"
disabled={ddpLogin.isPending || !ddpUser || !ddpPass}
onClick={() =>
ddpLogin.mutate(
{ username: ddpUser.trim(), password: ddpPass },
{
onSuccess: (r) => {
if (r.ok) {
setDdpPass("");
void ddpStatus.refetch();
toast.success(
t(locale, "settings.ddpLoginOk"),
);
} else {
toast.error(
r.message ??
t(locale, "settings.ddpLoginFailed"),
);
}
},
onError: () =>
toast.error(t(locale, "settings.ddpLoginFailed")),
},
)
}
>
{label("settings.ddpLoginBtn")}
</Button>
</div>
)}
</div>
</Card> </Card>
); );
} }

View File

@ -197,3 +197,62 @@ export async function openAnimeEpisodes(animeId: number): Promise<OpenEpisodesOu
const body = res.json as RawAnime; const body = res.json as RawAnime;
return { ok: true, anime: parseAnime(body) }; return { ok: true, anime: parseAnime(body) };
} }
/** 会话 token(登录弹弹play 后设置;请求附加 Authorization) */
let sessionToken: string | null = null;
export function setSessionToken(token: string | null): void {
sessionToken = token;
}
export function getSessionToken(): string | null {
return sessionToken;
}
function authHeaders(): Record<string, string> {
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<import("./types.js").LoginResult> {
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,
};
}

View File

@ -15,6 +15,9 @@ export {
openMatch, openMatch,
openSearchEpisodes, openSearchEpisodes,
readOpenCredentials, readOpenCredentials,
loginAccount,
setSessionToken,
getSessionToken,
setOpenCredentials, setOpenCredentials,
effectiveCredentials, effectiveCredentials,
} from "./client.js"; } from "./client.js";

View File

@ -17,3 +17,10 @@ export type MatchCandidate = {
episodeTitle: string | null; episodeTitle: string | null;
imageUrl: string | null; imageUrl: string | null;
}; };
export type LoginResult = {
ok: boolean;
token: string | null;
screenName: string | null;
message?: string | undefined;
};

View File

@ -343,4 +343,14 @@ export const en: Record<MessageKey, string> = {
"settings.openDevCenter": "Open DevCenter", "settings.openDevCenter": "Open DevCenter",
"settings.obtainSteps": "settings.obtainSteps":
"Steps: sign in / register at DevCenter → create an app → wait for review → copy AppId and AppSecret from the app detail", "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",
}; };

View File

@ -333,6 +333,16 @@ export const zhCN = {
"settings.openDevCenter": "打开开发者中心", "settings.openDevCenter": "打开开发者中心",
"settings.obtainSteps": "settings.obtainSteps":
"步骤:登录/注册开发者中心 → 创建应用 → 等待审核 → 在应用详情复制 AppId 与 AppSecret", "步骤:登录/注册开发者中心 → 创建应用 → 等待审核 → 在应用详情复制 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; } as const;
export type MessageKey = keyof typeof zhCN; export type MessageKey = keyof typeof zhCN;

View File

@ -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_ID = "open_danmaku_app_id";
export const APP_SETTING_OPEN_DANMAKU_APP_SECRET = "open_danmaku_app_secret"; 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";

View File

@ -21,5 +21,7 @@ export {
appSettings, appSettings,
APP_SETTING_OPEN_DANMAKU_APP_ID, APP_SETTING_OPEN_DANMAKU_APP_ID,
APP_SETTING_OPEN_DANMAKU_APP_SECRET, APP_SETTING_OPEN_DANMAKU_APP_SECRET,
APP_SETTING_DANDANPLAY_TOKEN,
APP_SETTING_DANDANPLAY_SCREEN_NAME,
} from "./app-settings.js"; } from "./app-settings.js";
export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js"; export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js";

View File

@ -11,4 +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
.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()),
}); });

View File

@ -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 { appSettingsDao } from "@app/dao";
import type { OpenNetworkSettingsOutput, SaveOpenNetworkSettingsInput } from "@app/types"; 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 { function envCredentials(): OpenCredentials | null {
const appId = process.env["OPEN_DANMAKU_APP_ID"]; const appId = process.env["OPEN_DANMAKU_APP_ID"];
@ -71,6 +81,7 @@ export async function ensureOpenRuntime(): Promise<void> {
if (runtimeApplied) return; if (runtimeApplied) return;
try { try {
await applyRuntimeOpenCredentials(); await applyRuntimeOpenCredentials();
await applyRuntimeSession();
} catch { } catch {
// 表未迁移或 DB 不可用时回退 env // 表未迁移或 DB 不可用时回退 env
} }
@ -82,4 +93,75 @@ export const settingsService = {
saveOpenNetworkSettings, saveOpenNetworkSettings,
applyRuntimeOpenCredentials, applyRuntimeOpenCredentials,
ensureOpenRuntime, 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<void> {
try {
const tokenRow = await appSettingsDao.get(APP_SETTING_DANDANPLAY_TOKEN);
setSessionToken(tokenRow?.value || null);
} catch {
setSessionToken(null);
}
}

View File

@ -147,6 +147,19 @@ export const settingsSchemas = {
appSecret: z.string().max(500).default(""), appSecret: z.string().max(500).default(""),
}), }),
getOpenNetwork: z.object({}).optional(), 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 明文仅供管理员读改。 */ /** 开放弹幕网络配置(管理员)。secret 明文仅供管理员读改。 */