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:
parent
fd70f649ae
commit
4fe0f40ce4
|
|
@ -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")}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,3 +197,62 @@ export async function openAnimeEpisodes(animeId: number): Promise<OpenEpisodesOu
|
|||
const body = res.json as RawAnime;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export {
|
|||
openMatch,
|
||||
openSearchEpisodes,
|
||||
readOpenCredentials,
|
||||
loginAccount,
|
||||
setSessionToken,
|
||||
getSessionToken,
|
||||
setOpenCredentials,
|
||||
effectiveCredentials,
|
||||
} from "./client.js";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -343,4 +343,14 @@ export const en: Record<MessageKey, string> = {
|
|||
"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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
||||
try {
|
||||
const tokenRow = await appSettingsDao.get(APP_SETTING_DANDANPLAY_TOKEN);
|
||||
setSessionToken(tokenRow?.value || null);
|
||||
} catch {
|
||||
setSessionToken(null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 明文仅供管理员读改。 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue