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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ export {
|
||||||
openMatch,
|
openMatch,
|
||||||
openSearchEpisodes,
|
openSearchEpisodes,
|
||||||
readOpenCredentials,
|
readOpenCredentials,
|
||||||
|
loginAccount,
|
||||||
|
setSessionToken,
|
||||||
|
getSessionToken,
|
||||||
setOpenCredentials,
|
setOpenCredentials,
|
||||||
effectiveCredentials,
|
effectiveCredentials,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 明文仅供管理员读改。 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue