feat(fm): let each web user link their own NetEase account for personal FM (#164)

With several people sharing one bot, personal FM always followed the one
account the bot was logged in with. Each signed-in (non-guest) web user
can now scan a QR code under Settings → 账户 to link their own NetEase
account; FM they start from the WebUI then comes from their account.

- user_music_cookies table (per user + platform, dropped with the user).
- NeteaseProvider.pollQrLogin returns the cookie without storing it, so
  a personal login can never replace the bot's shared account;
  checkQrCodeStatus is now built on it. withCookie gives a view bound to
  another account.
- /api/me/music/netease: status / qrcode / qrcode/status / unlink, acting
  only on req.user. The cookie never leaves the server.
- POST /api/player/:botId/fm uses the caller's linked account for
  NetEase. Songs still resolve through the shared provider when played.

TeamSpeak chat !fm keeps using the shared account: chat users are not
tied to web accounts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TIANYAO ZHANGandClaude Opus 5.5 committed 2026-09-27 22:02:31 +08:00
1 parent 8ff51ea6e0
commit ac4a12d8bd
11 files changed
+652 -11

No files matched your search

+33 -9
View File
@@ -111,8 +111,10 @@ export class NeteaseProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
@@ -243,25 +245,47 @@ export class NeteaseProvider implements MusicProvider {
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const { status, cookie } = await this.pollQrLogin(key);
if (cookie) this.cookie = cookie;
return status;
}
/**
* Poll a QR login and hand back the resulting cookie WITHOUT storing it on
* this provider — for a web user linking their own account (#164), which
* must never replace the bot's shared login.
*/
async pollQrLogin(
key: string
): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }> {
const res = await this.api.get("/login/qr/check", {
params: { key, timestamp: Date.now() },
});
const code = res.data?.code;
switch (code) {
switch (res.data?.code) {
case 801:
return "waiting";
return { status: "waiting" };
case 802:
return "scanned";
return { status: "scanned" };
case 803:
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
return res.data?.cookie
? { status: "confirmed", cookie: res.data.cookie }
: { status: "confirmed" };
default:
return "expired";
return { status: "expired" };
}
}
/**
* A provider for the same API server logged in as another account (#164):
* a web user's personal FM uses their own taste instead of the shared login.
*/
withCookie(cookie: string): NeteaseProvider {
const view = new NeteaseProvider(this.baseUrl);
view.setQuality(this.quality);
view.setCookie(cookie);
return view;
}
async sendSmsCode(phone: string): Promise<boolean> {
const res = await this.api.get("/captcha/sent", {
params: { phone },