Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 07ad861ecf fix(bilibili): keep long videos playing when the CDN drops the stream (#161)
Long B站 videos (2-3 h) still stopped ~15-20 min in, the same symptom as
#89. Reconnecting to the same URL is not enough once the CDN session is
gone, so:

- Prefer an upos/cos mirror from baseUrl + backupUrl over PCDN hosts
  (*.mcdn.bilivideo.cn, *.szbdyd.com), which are the ones that cut off.
- When a B站 track ends more than 30 s before its known duration, fetch
  a fresh URL and resume at the current position instead of advancing.
  Up to 3 attempts without real progress, then advance as before; a
  track the user started meanwhile is never clobbered.
- Seek B站 URLs input-side (-ss before -i). Their CDN serves Range, so a
  resume jumps to the byte offset instead of re-downloading everything
  before it (measured locally: 0.2 s vs a full-file download at 1.5 h
  into a 2 h fMP4), which would otherwise trip the 60 s stall watchdog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:52:47 +08:00
23 changed files with 291 additions and 1111 deletions

No files matched your search

+4 -56
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,35 +136,14 @@ ports:
</details>
### 方式四: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 服务:**
### 方式四:Linux 一键安装
```bash
chmod +x scripts/install.sh
sudo ./scripts/install.sh
# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f
```
**只安装构建(不装 Node、不配服务):**
```bash
bash scripts/setup.sh
npm start
```
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
## 更新升级
@@ -385,7 +364,6 @@ sudo systemctl start tsmusicbot
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认网易云,自动续播) |
@@ -517,7 +495,6 @@ teamspeak-music-bot/
├── scripts/ # 部署脚本
│ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
@@ -907,36 +884,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 — v1.14.0:歌单链接直接播放 / 每人绑定自己的网易云私人FM / B站分P
处理了 5 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;数据库会自动新增一张表(存放用户自己绑定的网易云账号),原有数据不受影响。
**`!playlist` 直接粘贴歌单链接([#160](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/160),[PR #169](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/169),感谢 [@JiaxiangACE](https://github.com/JiaxiangACE))**
- `!playlist <歌单链接>` 支持网易云 / QQ 音乐 / YouTube 歌单链接,**平台由链接自动识别**:以前 QQ 链接不加 `-q` 会被拿去网易云查,YouTube 的 `?list=` 链接会被当成歌单名去搜索,现在都能直接用。
- App 里「分享」复制出来的整段文案、以及短链(`163cn.tv`、`c6.y.qq.com`)也能直接粘贴。短链只会访问这两个域名,不会去请求任意用户给的地址。
- 歌单名和纯数字 ID 的用法不变。
**每个网页端用户绑定自己的网易云账号听私人FM([#164](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/164),[PR #171](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/171),感谢 [@xxmod](https://github.com/xxmod))**
- 多人共用一个机器人时,私人FM以前永远按机器人登录的那一个账号推荐。现在每个成员可以在 **设置 → 账户** 扫码绑定自己的网易云账号,之后他在网页端开启的网易云私人FM按他自己的口味推荐;未绑定的人照旧使用共享账号。
- 绑定的登录只保存在服务器上,从不回传给浏览器,也**不会**顶掉机器人的共享登录;删除用户时一并清除。游客不能绑定。
- TS 聊天里的 `!fm` 仍使用共享账号(聊天里的 TS 用户和网页账号没有对应关系)。
**机器人被移动后,原频道描述不再残留([#159](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/159),[PR #168](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/168),感谢 [@Almighty-ap](https://github.com/Almighty-ap))**
- 开启「更新频道描述」时,把机器人拖到别的频道后,原频道会一直停留在当时的歌曲信息。现在机器人被移动时会清空原频道描述,并把正在播放的信息写到新频道。
**Linux 安装脚本([#165](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/165),[PR #172](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/172),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
- `scripts/install.sh` 以前仍在安装已不再支持的 Node 20,现在按发行版(apt / yum / pacman)安装 Node 22 LTS,并复用 `setup.sh` 完成依赖安装、国内镜像切换、原生模块校验和构建。重复运行(升级)时会先停服务、替换构建产物,**保留 `data/`**。
- README 的「Linux 安装脚本」一节说明了 `install.sh`(一键安装 + systemd 开机自启)和 `setup.sh`(只安装构建、不装 Node、不配服务)的区别和适用场景。
**B站分P视频([PR #166](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/166),感谢 [@xxmod](https://github.com/xxmod))**
- 分P视频以前只能播放第一P,且时长显示为整个视频的总时长。现在网页端播放多P视频时会弹出选择框选P;TS 里 `!play` 播放第一P。
### v1.13.0:本地视频上传播放 / 头像上传时机
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
+21 -63
View File
@@ -1,16 +1,6 @@
#!/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 "╚══════════════════════════════════════╝"
@@ -21,9 +11,6 @@ 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
@@ -41,66 +28,42 @@ else
exit 1
fi
# 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..."
echo "[1/6] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg
sudo apt-get install -y -qq curl build-essential python3
;;
centos|rhel|fedora|rocky|almalinux)
centos|rhel|fedora)
sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg
sudo pacman -S --noconfirm curl base-devel python
;;
*)
echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually."
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
;;
esac
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
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)
fi
echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
bash "$SCRIPT_DIR/setup.sh"
echo "[4/6] Building project..."
npm run build
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
echo "[5/6] Copying to $INSTALL_DIR..."
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/"
@@ -109,18 +72,13 @@ 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 "[5/5] Creating systemd service..."
NODE_BIN="$(command -v node)"
echo "[6/6] Creating systemd service..."
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -130,7 +88,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -141,7 +99,7 @@ EOL
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl restart ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
echo ""
echo "╔══════════════════════════════════════╗"
+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 22.12+ LTS from https://nodejs.org"
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
+8
View File
@@ -64,6 +64,14 @@ describe("buildFfmpegArgs", () => {
expect(ssIdx).toBeGreaterThan(iIdx);
});
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
const ssIdx = args.indexOf("-ss");
expect(args[ssIdx + 1]).toBe("3600");
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
});
it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss");
+9 -2
View File
@@ -76,8 +76,9 @@ export function cleanupTempDir(dir: string): void {
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
if (isBilibili) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
@@ -103,9 +104,15 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
// to the byte offset. Output-side seek would download and decode everything
// before the target first — minutes for a resume deep into a 3-hour video
// (#161), long enough to trip the stall watchdog.
const inputSideSeek = isBilibili;
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
+78 -51
View File
@@ -1591,69 +1591,96 @@ describe("BotInstance Bilibili multi-P resolution", () => {
});
describe("cmdPlaylist with a playlist link (#160)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
) => Promise<string>;
describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => {
const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as (
this: unknown,
) => Promise<boolean>;
function makeCtx() {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
const makeProvider = (platform: string) => ({
platform,
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) {
const song: any = {
id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "",
platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old",
};
const queued: any[] = [];
return {
providers,
queued,
getProvider: vi.fn(() => providers.netease),
getProviderFor: vi.fn((p: string) => providers[p]),
assertProviderEnabled: vi.fn(),
extractId: (BotInstance.prototype as any).extractId,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
player: { stop: vi.fn() },
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
disableFmMode: vi.fn(),
withRequester: (s: any) => s,
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
let elapsed = opts.elapsed ?? 1000;
let state: "idle" | "playing" = "idle";
const provider = {
getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })),
};
const ctx: any = {
song,
provider,
connected: true,
effectiveDuration: song.duration,
streamRecovery: null,
queue: { current: vi.fn(() => song) },
player: {
getElapsed: vi.fn(() => elapsed),
getState: vi.fn(() => state),
play: vi.fn(() => { state = "playing"; }),
},
getProviderFor: vi.fn(() => provider),
logger: { warn: vi.fn(), info: vi.fn() },
emit: vi.fn(),
setElapsed: (v: number) => { elapsed = v; state = "idle"; },
};
return ctx;
}
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
const ctx = makeCtx();
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
expect(ctx.queued[0].platform).toBe("qq");
expect(reply).toMatch(/^Loaded 1 songs/);
it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => {
const ctx = makeCtx({ elapsed: 1000, duration: 10_000 });
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc");
expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000);
expect(ctx.song.url).toBe("https://fresh.test/a.m4s");
});
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
it("does nothing near the real end of the track (normal EOF)", async () => {
const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 });
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.provider.getSongUrl).not.toHaveBeenCalled();
});
it("checks the link's platform is enabled", async () => {
const ctx = makeCtx();
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
it("does nothing for other platforms or an unknown duration", async () => {
expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false);
const unknown = makeCtx({ duration: 0 });
unknown.effectiveDuration = 0;
expect(await resumeInterruptedStream.call(unknown)).toBe(false);
});
it("keeps the old behavior for a bare id", async () => {
it("gives up after 3 attempts that make no progress, then lets the queue advance", async () => {
const ctx = makeCtx({ elapsed: 1000 });
for (let i = 0; i < 3; i++) {
ctx.setElapsed(1000);
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
}
ctx.setElapsed(1000);
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.player.play).toHaveBeenCalledTimes(3);
});
it("resets the attempt budget once a resume actually plays on for a while", async () => {
const ctx = makeCtx({ elapsed: 1000 });
for (let i = 0; i < 3; i++) {
ctx.setElapsed(1000);
await resumeInterruptedStream.call(ctx);
}
ctx.setElapsed(2000); // the last resume played ~16 more minutes
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
});
it("falls through to advancing when no fresh URL can be fetched", async () => {
const ctx = makeCtx({ url: null });
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.player.play).not.toHaveBeenCalled();
});
it("does not clobber a different track the user started while the URL was resolving", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282"));
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
ctx.provider.getSongUrl.mockImplementation(async () => {
ctx.queue.current.mockReturnValue({ id: "other" }); // user ran !next meanwhile
return { url: "https://fresh.test/a.m4s" };
});
expect(await resumeInterruptedStream.call(ctx)).toBe(true); // handled: don't advance again
expect(ctx.player.play).not.toHaveBeenCalled();
});
});
+80 -32
View File
@@ -13,13 +13,7 @@ import {
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import {
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
@@ -199,6 +193,8 @@ export class BotInstance extends EventEmitter {
private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined;
/** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */
private streamRecovery: { song: QueuedSong; attempts: number; position: number } | null = null;
private playGate: Promise<unknown> = Promise.resolve();
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */
@@ -328,8 +324,17 @@ export class BotInstance extends EventEmitter {
});
this.player.on("trackEnd", () => {
this.resumeInterruptedStream()
.catch((err) => {
this.logger.warn({ err }, "Stream resume failed");
return false;
})
.then((resumed) => {
if (resumed) return;
this.logger.debug("Track ended, advancing queue");
this.playNext().catch((err) => {
return this.playNext();
})
.catch((err) => {
this.logger.error({ err }, "playNext failed after trackEnd");
});
});
@@ -462,16 +467,11 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
this.tsClient.on("clientMoved", (event: { id: number }) => {
if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
this.voiceDucking.reset(false);
// Carry the now-playing channel description over to the new
// channel instead of leaving it stale in the old one (#159).
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
this.logger.warn({ err }, "Channel description move update failed");
});
} else {
this.voiceDucking.removeSpeaker(event.id);
}
@@ -1127,6 +1127,69 @@ export class BotInstance extends EventEmitter {
}
}
/** Platforms whose CDN stream can die mid-file on long content (#89, #161). */
private static readonly RESUMABLE_PLATFORMS: ReadonlySet<Platform> = new Set(["bilibili"]);
/** A track that ends within this many seconds of its duration ended normally. */
private static readonly STREAM_END_TOLERANCE_S = 30;
private static readonly MAX_STREAM_RESUMES = 3;
/**
* Called when the player reports a track end. If a B站 stream ended long
* before its known duration, the CDN dropped it (#161): fetch a fresh URL
* and continue from where it stopped instead of skipping the rest of a
* 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no
* real progress, so a truly broken stream still advances the queue.
*
* Returns true when it handled the end (resumed, or a newer track has
* already taken over), false when the caller should advance the queue.
*/
private async resumeInterruptedStream(): Promise<boolean> {
const song = this.queue.current();
if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) {
return false;
}
const duration = this.effectiveDuration ?? song.duration;
const position = Math.floor(this.player.getElapsed());
if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) {
return false;
}
const recovery = this.streamRecovery;
if (
!recovery ||
recovery.song !== song ||
position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S
) {
this.streamRecovery = { song, attempts: 0, position };
}
const state = this.streamRecovery!;
if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) {
this.logger.warn(
{ songId: song.id, position, duration, attempts: state.attempts },
"Stream keeps ending early — giving up and advancing",
);
this.streamRecovery = null;
return false;
}
state.attempts++;
state.position = position;
this.logger.warn(
{ songId: song.id, position, duration, attempt: state.attempts },
"Stream ended before the track did — resuming with a fresh URL",
);
const result = await this.getProviderFor(song.platform).getSongUrl(song.id);
// The user may have skipped/stopped while we were resolving; never
// clobber whatever is playing now.
if (this.queue.current() !== song || this.player.getState() !== "idle") return true;
if (!result?.url || !this.connected) return false;
song.url = result.url;
this.player.play(result.url, position, duration);
this.emit("stateChange");
return true;
}
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try {
await this.profileManager.onSongChange(song);
@@ -1524,21 +1587,8 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args);
@@ -1546,9 +1596,7 @@ export class BotInstance extends EventEmitter {
let playlistId: string;
if (ref) {
playlistId = ref.id;
} else if (isDirectId || id !== cmd.args) {
if (isDirectId || id !== cmd.args) {
// Input is a direct ID or URL containing an ID — use existing logic
playlistId = id;
} else {
-62
View File
@@ -202,65 +202,3 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
describe("BotProfileManager channel description follows the bot (#159)", () => {
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
let channelEdits: () => string[];
beforeEach(() => {
ts = makeMockTs() as any;
ts.cid = 5n;
(ts as any).getChannelId = () => ts.cid;
channelEdits = () =>
(ts.sendCommandNoWait as any).mock.calls
.map((c: any[]) => c[0] as string)
.filter((cmd: string) => cmd.startsWith("channeledit"));
});
it("clears the old channel and fills the new one when moved while playing", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
expect(channelEdits()).toEqual([
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
]);
ts.cid = 9n;
await pm.onChannelMoved(9n);
const edits = channelEdits();
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
});
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
await pm.onSongChange(null);
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
});
it("a move while idle touches no channel description", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("a move is ignored when the channel description feature is off", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("an event for the channel the description is already in is a no-op", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
await pm.onChannelMoved(5n);
expect(channelEdits()).toHaveLength(1);
});
});
+5 -47
View File
@@ -32,13 +32,6 @@ export class BotProfileManager {
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/**
* Channel whose description currently holds our now-playing text, or null
* if we have not written one. Remembered so that when the bot is moved we
* can still clean up the channel it was taken out of (#159) — by then
* getChannelId() already reports the new channel.
*/
private channelDescCid: bigint | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
@@ -141,8 +134,6 @@ export class BotProfileManager {
onConnect(): void {
this.generation++;
this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = {
avatar: false,
description: false,
@@ -159,31 +150,6 @@ export class BotProfileManager {
}
}
/**
* Called when the bot itself has been moved to another channel (#159).
* Clears the now-playing text from the channel it left and, if a song is
* playing, writes it to the channel it is in now.
*/
async onChannelMoved(newChannelId: bigint): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
const oldChannelId = this.channelDescCid;
if (oldChannelId === newChannelId) return;
try {
if (oldChannelId !== null) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${oldChannelId} channel_description=`,
);
this.channelDescCid = null;
}
} catch (err) {
this.handleFeatureError("channelDesc", err);
return;
}
if (this.currentSong) {
await this.updateChannelDescription(this.currentSong, newChannelId);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
@@ -434,25 +400,18 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = targetChannelId ?? this.tsClient.getChannelId();
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
// Prefer the channel we actually wrote to, in case a move event
// was missed and the bot is somewhere else now.
const target = this.channelDescCid ?? channelId;
if (target === 0n) return; // unknown channel
await this.tsClient.sendCommandNoWait(
`channeledit cid=${target} channel_description=`,
`channeledit cid=${channelId} channel_description=`,
);
this.channelDescCid = null;
return;
}
if (channelId === 0n) return; // unknown channel
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -463,7 +422,6 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
+1 -63
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
@@ -120,65 +120,3 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
expect(parseSelectionIndex("")).toBeNull();
});
});
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
expect(parsePlaylistRef("华语经典")).toBeNull();
expect(parsePlaylistRef("2829883282")).toBeNull();
expect(parsePlaylistRef("")).toBeNull();
});
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
});
it("does not mistake a NetEase userid= for the playlist id", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
});
it("parses QQ Music playlist URLs", () => {
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
});
it("parses YouTube playlist URLs by their list= id", () => {
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
});
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
});
it("finds the link inside an app's share text", () => {
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
});
});
describe("findShareShortLink (#160)", () => {
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
});
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
expect(findShareShortLink("华语经典")).toBeNull();
});
});
describe("resolveShareLink (#160)", () => {
it("returns the redirect target", async () => {
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
});
it("returns null when there is no redirect or the request fails", async () => {
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
});
});
-81
View File
@@ -1,5 +1,3 @@
import axios from "axios";
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
@@ -100,82 +98,3 @@ export function parseSelectionIndex(raw: string): number | null {
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
export interface PlaylistRef {
id: string;
platform: "netease" | "qq" | "youtube";
}
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
function stripUrlBBCode(text: string): string {
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
}
/**
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
* share text. The platform comes from the URL, so a QQ link works without
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
* the caller handles as before.
*/
export function parsePlaylistRef(raw: string): PlaylistRef | null {
const q = stripUrlBBCode(raw ?? "").trim();
if (!q) return null;
if (/music\.163\.com/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
if (/y\.qq\.com/i.test(q)) {
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
if (/youtube\.com|youtu\.be/i.test(q)) {
const m = /[?&]list=([\w-]+)/.exec(q);
if (m) return { id: m[1], platform: "youtube" };
}
return null;
}
/**
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
* short link — what the phone apps copy. Only these hosts are recognized so
* the bot never fetches an arbitrary user-supplied URL.
*/
export function findShareShortLink(raw: string): string | null {
const q = stripUrlBBCode(raw ?? "");
const m =
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
return m ? m[0] : null;
}
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
const redirectGet: RedirectGet = async (url) => {
const res = await axios.get(url, {
maxRedirects: 0,
timeout: 5000,
validateStatus: () => true,
responseType: "stream",
});
res.data?.destroy?.();
const location = res.headers.location;
return { status: res.status, location: typeof location === "string" ? location : undefined };
};
/** Follow a share short link one hop. Returns the target URL, or null. */
export async function resolveShareLink(
url: string,
get: RedirectGet = redirectGet,
): Promise<string | null> {
try {
const { status, location } = await get(url);
if (status < 300 || status >= 400 || !location) return null;
return new URL(location, url).toString();
} catch {
return null;
}
}
-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}`);
+42 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
@@ -162,3 +162,44 @@ describe("BiliBiliProvider multi-P support", () => {
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
describe("pickStableAudioUrl (#161 long streams dying mid-play)", () => {
const pcdn = "https://xy1x2x3x4xy.mcdn.bilivideo.cn:4483/upgcxcode/1/2/3/3-1-30280.m4s?e=x&deadline=1";
const szbdyd = "https://cn-hk-eq-01-01.szbdyd.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
const upos = "https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
const upos2 = "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
it("prefers an upos/cos mirror over a PCDN baseUrl", () => {
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd, upos] })).toBe(upos);
});
it("keeps the baseUrl when it is already a stable host", () => {
expect(pickStableAudioUrl({ baseUrl: upos, backupUrl: [upos2] })).toBe(upos);
});
it("accepts the snake_case field names", () => {
expect(pickStableAudioUrl({ base_url: pcdn, backup_url: [upos2] })).toBe(upos2);
});
it("falls back to the baseUrl when every candidate is PCDN", () => {
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd] })).toBe(pcdn);
});
it("returns undefined when there is no url at all", () => {
expect(pickStableAudioUrl({})).toBeUndefined();
});
it("getSongUrl returns the stable mirror of the best stream", async () => {
const p = new BiliBiliProvider();
(p as any).cidCache.set("BV1abc", 42);
(p as any).api = {
get: vi.fn().mockResolvedValue({
data: { data: { dash: { audio: [
{ bandwidth: 64000, baseUrl: "https://upos-sz-mirrorcos.bilivideo.com/low.m4s" },
{ bandwidth: 320000, baseUrl: pcdn, backupUrl: [upos] },
] } } },
}),
};
expect((await p.getSongUrl("BV1abc"))?.url).toBe(upos);
});
});
+32 -1
View File
@@ -42,6 +42,37 @@ export interface BiliVideoPartsResult {
parts: BiliVideoPart[];
}
/**
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.szbdyd.com). Their
* sessions get cut mid-file, which kills long streams partway (#89, #161),
* and a reconnect to the same host rarely recovers.
*/
const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i;
/**
* Pick the audio URL least likely to die mid-stream: the first upos/cos
* mirror among baseUrl + backupUrl, else the baseUrl as before.
*/
export function pickStableAudioUrl(stream: {
baseUrl?: string;
base_url?: string;
backupUrl?: string[];
backup_url?: string[];
}): string | undefined {
const primary = stream.baseUrl ?? stream.base_url;
const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter(
(u): u is string => typeof u === "string" && u.length > 0,
);
const stable = candidates.find((u) => {
try {
return !BILI_PCDN_HOST.test(new URL(u).hostname);
} catch {
return false;
}
});
return stable ?? primary;
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
@@ -353,7 +384,7 @@ export class BiliBiliProvider implements MusicProvider {
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
);
const biliUrl = best.baseUrl ?? best.base_url;
const biliUrl = pickStableAudioUrl(best);
return biliUrl ? { url: biliUrl } : null;
} catch {
return null;
-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';