Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 88e6b5a691 fix(install): bring install.sh up to Node 22 and document both Linux scripts (#165)
install.sh still installed Node 20 (dropped in #152), ran the Debian-only
NodeSource script on yum systems, and hard-coded /usr/bin/node. The
README only mentioned install.sh, not setup.sh.

install.sh now:
- installs Node 22 LTS from the right NodeSource repo per distro and
  checks the same 22.12+/24+ floor as setup.sh
- delegates npm install, mirror detection, native-binary checks and the
  build to setup.sh, so the two scripts share one install path
- stops the service and replaces dist/node_modules on re-install (data/
  is kept), copies bin/ (yt-dlp), and uses the real node path in the unit

README explains the difference between the two scripts and when to use
which. setup.sh's "Node.js not found" message no longer says 20+.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:42:59 +08:00
13 changed files with 103 additions and 680 deletions

No files matched your search

+25 -3
View File
@@ -42,7 +42,7 @@
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号)
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
@@ -136,14 +136,35 @@ ports:
</details>
### 方式四:Linux 一键安装
### 方式四:Linux 安装脚本
Linux 下有两个脚本,按需二选一:
| | `scripts/install.sh`(一键安装 + 系统服务) | `scripts/setup.sh`(只安装构建) |
|---|---|---|
| 适合 | 想开箱即用、开机自启的服务器 | 想自己决定怎么常驻(screen / tmux / pm2 / 自写服务)的用户,或 macOS |
| Node.js | 没有或版本过低时**自动安装 Node 22 LTS**(apt / yum / pacman) | **不会安装**,需先自行装好 Node 22.12+ |
| 系统依赖 | 自动安装构建工具(和 FFmpeg,作为内置 FFmpeg 的后备) | 不安装,只提示 |
| 安装位置 | 构建后复制到 `/opt/tsmusicbot`(重装时保留 `data/`) | 就在当前项目目录 |
| 系统服务 | 自动配置 systemd 服务 `tsmusicbot` 并开机自启 | **不配置服务**,完成后自己 `npm start` |
| 需要 root | 是(`sudo`) | 否 |
两者共用同一套安装逻辑:`install.sh` 会调用 `setup.sh` 完成依赖安装、国内网络镜像切换、原生模块校验和构建,然后再复制文件、配置服务。
**一键安装 + systemd 服务:**
```bash
chmod +x scripts/install.sh
sudo ./scripts/install.sh
# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f
```
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
**只安装构建(不装 Node、不配服务):**
```bash
bash scripts/setup.sh
npm start
```
## 更新升级
@@ -495,6 +516,7 @@ teamspeak-music-bot/
├── scripts/ # 部署脚本
│ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
+67 -25
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Installer (Linux, systemd)
# - Installs system packages and Node.js 22 LTS
# - Runs scripts/setup.sh to install dependencies, verify native binaries and build
# - Copies the build to /opt/tsmusicbot and registers a systemd service (auto-start on boot)
#
# Only want to build and run it yourself (no Node install, no service)?
# Use scripts/setup.sh instead — see README「Linux 安装脚本」.
#
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
@@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
# Node LTS line to install when a supported Node is missing. Keep in sync with
# package.json "engines" and the floor check in setup.sh (#152).
NODE_LTS_MAJOR=22
# Verify we're in a valid project directory
if [ ! -f "$PROJECT_DIR/package.json" ]; then
@@ -28,42 +41,66 @@ else
exit 1
fi
echo "[1/6] Installing system dependencies..."
# Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest).
node_supported() {
command -v node &> /dev/null &&
node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'
}
echo "[1/5] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl build-essential python3
sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg
;;
centos|rhel|fedora)
centos|rhel|fedora|rocky|almalinux)
sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
sudo pacman -S --noconfirm curl base-devel python
sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg
;;
*)
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually."
;;
esac
echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/6] Installing dependencies..."
cd "$PROJECT_DIR"
npm install
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
(cd "$PROJECT_DIR/web" && npm install)
echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..."
if node_supported; then
echo "Node.js $(node -v) already installed"
else
case $OS in
ubuntu|debian)
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
sudo apt-get install -y -qq nodejs
;;
centos|rhel|fedora|rocky|almalinux)
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
sudo yum install -y nodejs
;;
arch|manjaro)
sudo pacman -S --noconfirm --needed nodejs npm
;;
esac
if ! node_supported; then
echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)."
echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script."
exit 1
fi
echo "Node.js $(node -v) installed"
fi
echo "[4/6] Building project..."
npm run build
echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
bash "$SCRIPT_DIR/setup.sh"
echo "[5/6] Copying to $INSTALL_DIR..."
echo "[4/5] Copying to $INSTALL_DIR..."
# Stop a running copy before replacing its files (re-install / upgrade).
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
sudo systemctl stop "$SERVICE_NAME"
fi
sudo mkdir -p "$INSTALL_DIR"
# Replace build output wholesale so files removed upstream don't linger.
# data/ (config, database, cookies) is never touched.
sudo rm -rf "$INSTALL_DIR/dist" "$INSTALL_DIR/node_modules" "$INSTALL_DIR/web/dist"
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
@@ -72,13 +109,18 @@ if [ -d "$PROJECT_DIR/web/dist" ]; then
sudo mkdir -p "$INSTALL_DIR/web"
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
fi
# yt-dlp is looked up in bin/ next to dist/ before falling back to PATH
if [ -d "$PROJECT_DIR/bin" ]; then
sudo cp -r "$PROJECT_DIR/bin" "$INSTALL_DIR/"
fi
# Copy scripts for future use
sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory
sudo mkdir -p "$INSTALL_DIR/data"
echo "[6/6] Creating systemd service..."
echo "[5/5] Creating systemd service..."
NODE_BIN="$(command -v node)"
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,7 +130,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -99,15 +141,15 @@ EOL
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
sudo systemctl restart ${SERVICE_NAME}
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot installed and running! ║"
echo "║ ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ ║"
echo "║ Commands: ║"
echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
+1 -1
View File
@@ -21,7 +21,7 @@ echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
-33
View File
@@ -345,36 +345,3 @@ 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();
});
});
-39
View File
@@ -144,10 +144,6 @@ 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[];
@@ -332,17 +328,6 @@ 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
);
`);
}
@@ -468,17 +453,6 @@ 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[] => {
@@ -660,19 +634,6 @@ 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}`);
-45
View File
@@ -139,48 +139,3 @@ 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");
});
});
+9 -33
View File
@@ -111,10 +111,8 @@ 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,
@@ -245,47 +243,25 @@ 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() },
});
switch (res.data?.code) {
const code = res.data?.code;
switch (code) {
case 801:
return { status: "waiting" };
return "waiting";
case 802:
return { status: "scanned" };
return "scanned";
case 803:
return res.data?.cookie
? { status: "confirmed", cookie: res.data.cookie }
: { status: "confirmed" };
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
default:
return { status: "expired" };
return "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 },
-125
View File
@@ -1,125 +0,0 @@
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);
});
});
-95
View File
@@ -1,95 +0,0 @@
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<QrCodeResult>;
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<PersonalLoginProvider> | 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;
}
+1 -10
View File
@@ -6,7 +6,6 @@ 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,
@@ -125,19 +124,11 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res);
return;
}
let provider = bot.getProviderFor(
const 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:
-8
View File
@@ -19,7 +19,6 @@ 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";
@@ -204,13 +203,6 @@ 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(
@@ -1,261 +0,0 @@
<template>
<!-- The signed-in user's own NetEase account, used for THEIR 私人FM instead
of the bot's shared login (#164). -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:radio" class="account-icon" />
<div class="account-info">
<div class="account-name">我的网易云账号(私人FM)</div>
<div class="account-status" :class="{ logged: status.loggedIn }">
<template v-if="status.loggedIn">已绑定: {{ status.nickname }}</template>
<template v-else-if="status.linked">已绑定,但登录已失效,请重新扫码</template>
<template v-else>未绑定 — 私人FM使用机器人的共享账号</template>
</div>
</div>
</div>
<p class="hint">
绑定后,你在网页端开启的网易云私人FM会按你自己的口味推荐;其他人不受影响。
仅保存在服务器上,不会显示给任何人。
</p>
<div class="login-methods">
<button class="login-btn" :disabled="qr.loading" @click="startQrLogin">
<Icon icon="mdi:qrcode" />
{{ status.linked ? '重新扫码绑定' : '扫码绑定' }}
</button>
<button v-if="status.linked" class="login-btn" @click="unlink">
<Icon icon="mdi:link-off" />
解除绑定
</button>
</div>
<div v-if="qr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="qr.dataUrl" class="qr-wrap">
<img :src="qr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="qr.status">
<template v-if="qr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用网易云音乐APP扫码
</template>
<template v-else-if="qr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="qr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 绑定成功!
</template>
<template v-else-if="qr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin">重新生成</button>
</template>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import QRCode from 'qrcode';
const BASE = '/api/me/music/netease';
const status = reactive({ linked: false, loggedIn: false, nickname: '' });
const qr = reactive({
loading: false,
dataUrl: '',
key: '',
status: 'waiting' as 'waiting' | 'scanned' | 'confirmed' | 'expired',
});
const error = ref('');
let pollTimer: ReturnType<typeof setInterval> | null = null;
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
}
async function refreshStatus() {
try {
const res = await axios.get(`${BASE}/status`);
status.linked = Boolean(res.data?.linked);
status.loggedIn = Boolean(res.data?.loggedIn);
status.nickname = res.data?.nickname ?? '';
} catch {
// Leave the last known state
}
}
async function startQrLogin() {
stopPolling();
error.value = '';
qr.loading = true;
qr.dataUrl = '';
qr.status = 'waiting';
try {
const res = await axios.post(`${BASE}/qrcode`);
const { qrUrl, qrImg, key } = res.data;
qr.key = key;
// Dark-on-light only: many in-app scanners can't read an inverted code.
qr.dataUrl = qrImg || (await QRCode.toDataURL(qrUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}));
pollTimer = setInterval(pollQrStatus, 2000);
} catch (err: any) {
error.value = err?.response?.data?.error ?? '二维码生成失败';
} finally {
qr.loading = false;
}
}
async function pollQrStatus() {
if (!qr.key) return;
try {
const res = await axios.get(`${BASE}/qrcode/status`, { params: { key: qr.key } });
qr.status = res.data.status;
if (qr.status === 'confirmed') {
stopPolling();
await refreshStatus();
} else if (qr.status === 'expired') {
stopPolling();
}
} catch {
// Ignore poll errors
}
}
async function unlink() {
error.value = '';
try {
await axios.delete(BASE);
stopPolling();
qr.dataUrl = '';
await refreshStatus();
} catch (err: any) {
error.value = err?.response?.data?.error ?? '解除绑定失败';
}
}
onMounted(refreshStatus);
onUnmounted(stopPolling);
</script>
<style lang="scss" scoped>
.account-card {
margin-top: 16px;
padding: 20px;
background: var(--hover-bg);
border-radius: var(--radius-md);
}
.account-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.account-icon {
font-size: 28px;
color: var(--color-primary);
}
.account-name {
font-weight: 600;
}
.account-status {
font-size: 12px;
color: var(--text-tertiary);
&.logged { color: var(--color-online); }
}
.hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 12px;
line-height: 1.5;
}
.login-methods {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.login-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: inherit;
cursor: pointer;
transition: all var(--transition-fast);
&:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
&:disabled { opacity: 0.6; cursor: default; }
}
.qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.qr-image {
width: 200px;
height: 200px;
border-radius: var(--radius-md);
border: 2px solid var(--border-color);
}
.qr-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
&.confirmed { color: var(--color-online); }
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
}
.error {
margin-top: 8px;
font-size: 12px;
color: #e26a6a;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
-2
View File
@@ -48,7 +48,6 @@
</form>
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
</section>
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
@@ -1162,7 +1161,6 @@ 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';