diff --git a/README.md b/README.md index 187c45f..61be7d8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 - **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步 -- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单 +- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号) - **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) diff --git a/src/data/database.test.ts b/src/data/database.test.ts index b03ff1e..64da9dc 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -345,3 +345,36 @@ describe("guest principal migration", () => { rmSync(dir, { recursive: true, force: true }); }); }); + +describe("user music cookies (#164)", () => { + let botDb: BotDatabase; + const addUser = (id: string) => + botDb.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run(id, id, "x", 0, 0, "member"); + + beforeEach(() => { + botDb = createDatabase(":memory:"); + addUser("u1"); + addUser("u2"); + }); + afterEach(() => botDb.close()); + + it("stores, overwrites and deletes a cookie per user and platform", () => { + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a"); + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=b"); + expect(botDb.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=b"); + expect(botDb.getUserMusicCookie("u2", "netease")).toBeNull(); + expect(botDb.getUserMusicCookie("u1", "qq")).toBeNull(); + expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(true); + expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(false); + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + }); + + it("drops a user's cookies when the user is deleted", () => { + botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a"); + botDb.db.prepare("DELETE FROM users WHERE id = ?").run("u1"); + expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull(); + }); +}); diff --git a/src/data/database.ts b/src/data/database.ts index 595913f..ddfe048 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -144,6 +144,10 @@ export interface BotDatabase { removeFavorite(userId: string, playlistId: string, platform: string): boolean; getFavorites(userId: string): FavoritePlaylist[]; isFavorited(userId: string, playlistId: string, platform: string): boolean; + // Per-user music account cookies (#164). + getUserMusicCookie(userId: string, platform: string): string | null; + setUserMusicCookie(userId: string, platform: string, cookie: string): void; + deleteUserMusicCookie(userId: string, platform: string): boolean; // Saved queues (Feature 1) — upsert by (ownerId, name), capped. saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; @@ -328,6 +332,17 @@ function initTables(db: Database.Database): void { fmPlatform TEXT NOT NULL DEFAULT '', updatedAt TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- A web user's own music-platform login (#164), used for their personal + -- FM instead of the bot's shared account. Secret: never sent to clients. + CREATE TABLE IF NOT EXISTS user_music_cookies ( + userId TEXT NOT NULL, + platform TEXT NOT NULL, + cookie TEXT NOT NULL, + updatedAt TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (userId, platform), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); `); } @@ -453,6 +468,17 @@ export function createDatabase(dbPath: string): BotDatabase { SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ? `); + const selectUserMusicCookie = db.prepare( + `SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`, + ); + const upsertUserMusicCookie = db.prepare(` + INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?) + ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now') + `); + const deleteUserMusicCookieStmt = db.prepare( + `DELETE FROM user_music_cookies WHERE userId = ? AND platform = ?`, + ); + // A corrupt/hand-edited songs blob must never throw into a route or the // restore path — degrade to an empty list instead. const parseSongs = (raw: string): StoredSong[] => { @@ -634,6 +660,19 @@ export function createDatabase(dbPath: string): BotDatabase { return row !== undefined; }, + getUserMusicCookie(userId, platform) { + const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined; + return row?.cookie ?? null; + }, + + setUserMusicCookie(userId, platform, cookie) { + upsertUserMusicCookie.run(userId, platform, cookie); + }, + + deleteUserMusicCookie(userId, platform) { + return deleteUserMusicCookieStmt.run(userId, platform).changes > 0; + }, + saveQueue(ownerId, name, songs) { if (songs.length > MAX_QUEUE_SONGS) { throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`); diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index aff9f89..800689f 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -139,3 +139,48 @@ describe("NeteaseProvider.search pagination", () => { expect(callByType(get, 10).offset).toBe(0); }); }); + +describe("NeteaseProvider per-user login (#164)", () => { + function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) { + const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) })); + (p as any).api = { get, defaults: { baseURL: "http://127.0.0.1:3001" } }; + return get; + } + + it("pollQrLogin returns the cookie without touching the shared account", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + p.setCookie("MUSIC_U=shared"); + withGet(p, () => ({ code: 803, cookie: "MUSIC_U=personal" })); + expect(await p.pollQrLogin("k")).toEqual({ status: "confirmed", cookie: "MUSIC_U=personal" }); + expect(p.getCookie()).toBe("MUSIC_U=shared"); + }); + + it("pollQrLogin maps the waiting / scanned / expired codes", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + let code = 801; + withGet(p, () => ({ code })); + expect(await p.pollQrLogin("k")).toEqual({ status: "waiting" }); + code = 802; + expect(await p.pollQrLogin("k")).toEqual({ status: "scanned" }); + code = 800; + expect(await p.pollQrLogin("k")).toEqual({ status: "expired" }); + }); + + it("checkQrCodeStatus still stores the cookie on the shared provider (admin login)", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + withGet(p, () => ({ code: 803, cookie: "MUSIC_U=admin" })); + expect(await p.checkQrCodeStatus("k")).toBe("confirmed"); + expect(p.getCookie()).toBe("MUSIC_U=admin"); + }); + + it("withCookie gives a view that fetches FM with the other account's cookie", async () => { + const p = new NeteaseProvider("http://127.0.0.1:3001"); + p.setCookie("MUSIC_U=shared"); + const personal = p.withCookie("MUSIC_U=personal"); + const get = withGet(personal, () => ({ data: [] })); + await personal.getPersonalFm(); + expect(get.mock.calls[0][1].params.cookie).toBe("MUSIC_U=personal"); + expect(p.getCookie()).toBe("MUSIC_U=shared"); + expect(personal.platform).toBe("netease"); + }); +}); diff --git a/src/music/netease.ts b/src/music/netease.ts index 2a9acc8..05bcd14 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -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 { const res = await this.api.get("/captcha/sent", { params: { phone }, diff --git a/src/web/api/personal-music.test.ts b/src/web/api/personal-music.test.ts new file mode 100644 index 0000000..8faf1ad --- /dev/null +++ b/src/web/api/personal-music.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase } from "../../data/database.js"; +import { createPersonalMusicRouter } from "./personal-music.js"; +import { createPlayerRouter } from "./player.js"; + +function mount() { + const db = createDatabase(":memory:"); + db.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run("u1", "alice", "x", 0, 0, "member"); + const personalView = { + getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })), + }; + const provider: any = { + platform: "netease", + getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })), + pollQrLogin: vi.fn(async () => ({ status: "waiting" })), + withCookie: vi.fn(() => personalView), + setCookie: vi.fn(), + }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).user = { id: "u1", username: "alice", role: "member" }; + next(); + }); + app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" }))); + return { app, db, provider, personalView }; +} + +describe("personal music account router (#164)", () => { + it("reports not linked until the user logs in", async () => { + const { app } = mount(); + const res = await request(app).get("/api/me/music/netease/status"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ linked: false, loggedIn: false }); + }); + + it("creates a QR code", async () => { + const { app } = mount(); + const res = await request(app).post("/api/me/music/netease/qrcode"); + expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" }); + }); + + it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => { + const { app, db, provider } = mount(); + provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" }); + const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" }); + expect(res.body).toEqual({ status: "confirmed" }); + expect(JSON.stringify(res.body)).not.toContain("MUSIC_U"); + expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice"); + expect(provider.setCookie).not.toHaveBeenCalled(); + }); + + it("requires a key to poll", async () => { + const { app } = mount(); + expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400); + }); + + it("reports the linked account's nickname via a view on the user's cookie", async () => { + const { app, db, provider } = mount(); + db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + const res = await request(app).get("/api/me/music/netease/status"); + expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" }); + expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice"); + }); + + it("unlinks", async () => { + const { app, db } = mount(); + db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + expect((await request(app).delete("/api/me/music/netease")).status).toBe(200); + expect(db.getUserMusicCookie("u1", "netease")).toBeNull(); + }); +}); + +describe("web FM uses the caller's linked NetEase account (#164)", () => { + async function startFm(opts: { linked: boolean; role?: string; platform?: string }) { + const db = createDatabase(":memory:"); + db.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)") + .run("u1", "alice", "x", 0, 0, "member"); + if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice"); + const personal = { platform: "netease", personal: true }; + const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) }; + const qq: any = { platform: "qq" }; + const bot = { + id: "b1", + getProviderFor: (p: string) => (p === "qq" ? qq : shared), + startFm: vi.fn(async (_provider: unknown) => "Personal FM started"), + }; + const botManager: any = { getBot: () => bot }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).user = { + id: "u1", username: "alice", role: opts.role ?? "member", + capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true }, + }; + next(); + }); + app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db)); + const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" }); + return { res, bot, shared, personal, qq }; + } + + it("starts FM on the user's own account when linked", async () => { + const { res, bot, shared, personal } = await startFm({ linked: true }); + expect(res.status).toBe(200); + expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice"); + expect(bot.startFm.mock.calls[0][0]).toBe(personal); + }); + + it("falls back to the shared account when the user has not linked one", async () => { + const { bot, shared } = await startFm({ linked: false }); + expect(bot.startFm.mock.calls[0][0]).toBe(shared); + }); + + it("leaves other platforms alone", async () => { + const { bot, qq } = await startFm({ linked: true, platform: "qq" }); + expect(bot.startFm.mock.calls[0][0]).toBe(qq); + }); +}); diff --git a/src/web/api/personal-music.ts b/src/web/api/personal-music.ts new file mode 100644 index 0000000..dfc3007 --- /dev/null +++ b/src/web/api/personal-music.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import type { BotDatabase } from "../../data/database.js"; +import type { MusicProvider, QrCodeResult } from "../../music/provider.js"; +import type { Logger } from "../../logger.js"; + +/** + * A provider that can log a web user into their OWN account without touching + * the bot's shared login, and hand out a view bound to that account (#164). + */ +export interface PersonalLoginProvider { + getQrCode(): Promise; + pollQrLogin(key: string): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }>; + withCookie(cookie: string): MusicProvider; +} + +export function supportsPersonalLogin( + provider: MusicProvider | undefined, +): provider is MusicProvider & PersonalLoginProvider { + const p = provider as Partial | undefined; + return typeof p?.pollQrLogin === "function" && typeof p.withCookie === "function"; +} + +/** + * The caller's own NetEase account, used for their personal FM instead of the + * bot's shared login (#164). Every route acts on req.user only; the cookie is + * stored server-side and never sent back to the browser. + */ +export function createPersonalMusicRouter( + database: BotDatabase, + neteaseProvider: MusicProvider, + logger: Logger, +): Router { + const router = Router(); + const platform = "netease"; + + router.use((_req, res, next) => { + if (!supportsPersonalLogin(neteaseProvider)) { + res.status(501).json({ error: "Personal login not supported" }); + return; + } + next(); + }); + const provider = neteaseProvider as MusicProvider & PersonalLoginProvider; + + router.get("/netease/status", async (req, res) => { + const cookie = database.getUserMusicCookie(req.user!.id, platform); + if (!cookie) { + res.json({ linked: false, loggedIn: false }); + return; + } + try { + const status = await provider.withCookie(cookie).getAuthStatus(); + res.json({ linked: true, ...status }); + } catch (err) { + logger.warn({ err }, "Personal NetEase status check failed"); + res.json({ linked: true, loggedIn: false }); + } + }); + + router.post("/netease/qrcode", async (_req, res) => { + try { + res.json(await provider.getQrCode()); + } catch (err) { + logger.error({ err }, "Personal NetEase QR generation failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/netease/qrcode/status", async (req, res) => { + const key = req.query.key; + if (typeof key !== "string" || !key) { + res.status(400).json({ error: "key is required" }); + return; + } + try { + const { status, cookie } = await provider.pollQrLogin(key); + if (status === "confirmed" && cookie) { + database.setUserMusicCookie(req.user!.id, platform, cookie); + logger.info({ userId: req.user!.id, platform }, "Personal music account linked"); + } + res.json({ status }); + } catch (err) { + logger.error({ err }, "Personal NetEase QR status check failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.delete("/netease", (req, res) => { + database.deleteUserMusicCookie(req.user!.id, platform); + logger.info({ userId: req.user!.id, platform }, "Personal music account unlinked"); + res.json({ ok: true }); + }); + + return router; +} diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 8cdcac7..ab67326 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -6,6 +6,7 @@ import type { Logger } from "../../logger.js"; import { parseCommand } from "../../bot/commands.js"; import { requireBotAccess } from "../middleware/requirePermission.js"; import { authorize } from "../middleware/authorize.js"; +import { supportsPersonalLogin } from "./personal-music.js"; export function createPlayerRouter( botManager: BotManager, @@ -124,11 +125,19 @@ export function createPlayerRouter( rejectDisabledLocalAudio(res); return; } - const provider = bot.getProviderFor( + let provider = bot.getProviderFor( platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin" ? platform : "netease" ); + // A signed-in user who linked their own NetEase account gets FM from + // THEIR taste, not the bot's shared login (#164). Songs still resolve + // through the shared provider when played. + const user = (req as any).user; + if (provider.platform === "netease" && user && user.role !== "guest" && database) { + const cookie = database.getUserMusicCookie(user.id, "netease"); + if (cookie && supportsPersonalLogin(provider)) provider = provider.withCookie(cookie); + } const message = await bot.startFm(provider, requesterName(req)); res.json({ ok: diff --git a/src/web/server.ts b/src/web/server.ts index a94a4b5..045d7c7 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createPersonalMusicRouter } from "./api/personal-music.js"; import { createSavedQueuesRouter } from "./api/saved-queues.js"; import { createSpotifyRouter } from "./api/spotify.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; @@ -203,6 +204,13 @@ export function createWebServer(options: WebServerOptions): WebServer { ); } app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); + // The caller's own NetEase login for their personal FM (#164). Guests share + // one anonymous identity, so they cannot link an account. + app.use( + "/api/me/music", + requireNotGuest, + createPersonalMusicRouter(options.database, options.neteaseProvider, logger), + ); // Saved queues (Feature 1, #119). Members + admins only (requireNotGuest); // the router itself 403s every route unless savedQueuesEnabled is on. app.use( diff --git a/web/src/components/PersonalNeteaseAccount.vue b/web/src/components/PersonalNeteaseAccount.vue new file mode 100644 index 0000000..9fb3eec --- /dev/null +++ b/web/src/components/PersonalNeteaseAccount.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 229fb9e..acf1a51 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -48,6 +48,7 @@

{{ ownPwError }}

{{ ownPwSuccess }}

+ @@ -1161,6 +1162,7 @@ import { Icon } from '@iconify/vue'; import axios from 'axios'; import AvatarUpload from '../components/AvatarUpload.vue'; import CustomAvatarRow from '../components/CustomAvatarRow.vue'; +import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue'; import QRCode from 'qrcode'; import { usePlayerStore } from '../stores/player.js'; import { useSession } from '../composables/useSession.js';