mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87fca6d8b7 | ||
|
|
c7b1268281 | ||
|
|
b942a4726e | ||
|
|
ad728a3a04 | ||
|
|
c51d311ab6 | ||
|
|
ac4a12d8bd | ||
|
|
88e6b5a691 | ||
|
|
6b82df4e50 | ||
|
|
faf6ac09ec | ||
|
|
8ff51ea6e0 | ||
|
|
3c0e8df763 | ||
|
|
2ea02f54d9 | ||
|
|
1407cadf7b | ||
|
|
9fd1b39092 |
No files matched your search
@@ -42,7 +42,7 @@
|
|||||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
|
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号)
|
||||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||||
@@ -136,14 +136,35 @@ ports:
|
|||||||
|
|
||||||
</details>
|
</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
|
```bash
|
||||||
chmod +x scripts/install.sh
|
chmod +x scripts/install.sh
|
||||||
sudo ./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
|
||||||
|
```
|
||||||
|
|
||||||
## 更新升级
|
## 更新升级
|
||||||
|
|
||||||
@@ -364,6 +385,7 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
|
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
|
||||||
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
||||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
||||||
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
||||||
@@ -495,6 +517,7 @@ teamspeak-music-bot/
|
|||||||
├── scripts/ # 部署脚本
|
├── scripts/ # 部署脚本
|
||||||
│ ├── setup.bat # Windows 首次安装
|
│ ├── setup.bat # Windows 首次安装
|
||||||
│ ├── start.bat # Windows 启动脚本
|
│ ├── start.bat # Windows 启动脚本
|
||||||
|
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
|
||||||
│ ├── install.sh # Linux 一键安装 + systemd 服务
|
│ ├── install.sh # Linux 一键安装 + systemd 服务
|
||||||
│ └── docker/ # Docker 部署文件
|
│ └── docker/ # Docker 部署文件
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
@@ -884,7 +907,36 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
|||||||
|
|
||||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||||
|
|
||||||
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
|
### 最新版本 — 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:本地视频上传播放 / 头像上传时机
|
||||||
|
|
||||||
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
|
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
|
||||||
|
|
||||||
|
|||||||
+67
-25
@@ -1,6 +1,16 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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 "╔══════════════════════════════════════╗"
|
||||||
echo "║ TSMusicBot Installer ║"
|
echo "║ TSMusicBot Installer ║"
|
||||||
echo "╚══════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════╝"
|
||||||
@@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
INSTALL_DIR="/opt/tsmusicbot"
|
INSTALL_DIR="/opt/tsmusicbot"
|
||||||
SERVICE_NAME="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
|
# Verify we're in a valid project directory
|
||||||
if [ ! -f "$PROJECT_DIR/package.json" ]; then
|
if [ ! -f "$PROJECT_DIR/package.json" ]; then
|
||||||
@@ -28,42 +41,66 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
case $OS in
|
||||||
ubuntu|debian)
|
ubuntu|debian)
|
||||||
sudo apt-get update -qq
|
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
|
sudo yum install -y curl gcc gcc-c++ make python3
|
||||||
;;
|
;;
|
||||||
arch|manjaro)
|
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
|
esac
|
||||||
|
|
||||||
echo "[2/6] Installing Node.js 20 LTS..."
|
echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..."
|
||||||
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
|
if node_supported; then
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
echo "Node.js $(node -v) already installed"
|
||||||
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
|
else
|
||||||
fi
|
case $OS in
|
||||||
echo "Node.js $(node -v) installed"
|
ubuntu|debian)
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
|
||||||
echo "[3/6] Installing dependencies..."
|
sudo apt-get install -y -qq nodejs
|
||||||
cd "$PROJECT_DIR"
|
;;
|
||||||
npm install
|
centos|rhel|fedora|rocky|almalinux)
|
||||||
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
|
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
|
||||||
(cd "$PROJECT_DIR/web" && npm install)
|
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
|
fi
|
||||||
|
|
||||||
echo "[4/6] Building project..."
|
echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
|
||||||
npm run build
|
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"
|
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/dist" "$INSTALL_DIR/"
|
||||||
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
|
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
|
||||||
sudo cp "$PROJECT_DIR/package.json" "$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 mkdir -p "$INSTALL_DIR/web"
|
||||||
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
|
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
|
||||||
fi
|
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
|
# Copy scripts for future use
|
||||||
sudo mkdir -p "$INSTALL_DIR/scripts"
|
sudo mkdir -p "$INSTALL_DIR/scripts"
|
||||||
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
|
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
|
||||||
# Create data directory
|
# Create data directory
|
||||||
sudo mkdir -p "$INSTALL_DIR/data"
|
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
|
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=TSMusicBot - TeamSpeak Music Bot
|
Description=TSMusicBot - TeamSpeak Music Bot
|
||||||
@@ -88,7 +130,7 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
|
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
@@ -99,15 +141,15 @@ EOL
|
|||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable ${SERVICE_NAME}
|
sudo systemctl enable ${SERVICE_NAME}
|
||||||
sudo systemctl start ${SERVICE_NAME}
|
sudo systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "╔══════════════════════════════════════╗"
|
echo "╔══════════════════════════════════════╗"
|
||||||
echo "║ TSMusicBot installed and running! ║"
|
echo "║ TSMusicBot installed and running! ║"
|
||||||
echo "║ ║"
|
echo "║ ║"
|
||||||
echo "║ WebUI: http://localhost:3000 ║"
|
echo "║ WebUI: http://localhost:3000 ║"
|
||||||
echo "║ ║"
|
echo "║ ║"
|
||||||
echo "║ Commands: ║"
|
echo "║ Commands: ║"
|
||||||
echo "║ systemctl status tsmusicbot ║"
|
echo "║ systemctl status tsmusicbot ║"
|
||||||
echo "║ systemctl restart tsmusicbot ║"
|
echo "║ systemctl restart tsmusicbot ║"
|
||||||
echo "║ systemctl stop tsmusicbot ║"
|
echo "║ systemctl stop tsmusicbot ║"
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ echo ""
|
|||||||
|
|
||||||
# ---- Check Node.js ----
|
# ---- Check Node.js ----
|
||||||
if ! command -v node &>/dev/null; then
|
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)."
|
echo " or https://nodejs.cn/ (China mirror)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ describe("buildFfmpegArgs", () => {
|
|||||||
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
it("inserts -ss after -i when seekSeconds > 0", () => {
|
||||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||||
const ssIdx = args.indexOf("-ss");
|
const ssIdx = args.indexOf("-ss");
|
||||||
const iIdx = args.indexOf("-i");
|
const iIdx = args.indexOf("-i");
|
||||||
expect(ssIdx).toBeGreaterThan(-1);
|
expect(ssIdx).toBeGreaterThan(-1);
|
||||||
expect(args[ssIdx + 1]).toBe("42");
|
expect(args[ssIdx + 1]).toBe("42");
|
||||||
expect(ssIdx).toBeLessThan(iIdx);
|
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not insert -ss when seekSeconds is 0", () => {
|
it("does not insert -ss when seekSeconds is 0", () => {
|
||||||
|
|||||||
+3
-1
@@ -103,8 +103,10 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
"-reconnect_on_http_error", "4xx,5xx",
|
"-reconnect_on_http_error", "4xx,5xx",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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) args.push("-ss", String(seekSeconds));
|
||||||
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1538,3 +1538,122 @@ describe("BotInstance live-queue persistence (#119)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BotInstance Bilibili multi-P resolution", () => {
|
||||||
|
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
|
||||||
|
const multiPSongDetail = {
|
||||||
|
id: "BV1multiP?p=1",
|
||||||
|
name: "测试视频 - P1 分P1",
|
||||||
|
artist: "UP主",
|
||||||
|
album: "",
|
||||||
|
duration: 100, // P1 duration
|
||||||
|
coverUrl: "",
|
||||||
|
platform: "bilibili" as const,
|
||||||
|
};
|
||||||
|
const mockBili = {
|
||||||
|
platform: "bilibili" as const,
|
||||||
|
search: vi.fn().mockResolvedValue({
|
||||||
|
songs: [{
|
||||||
|
id: "BV1multiP",
|
||||||
|
name: "测试视频",
|
||||||
|
artist: "UP主",
|
||||||
|
album: "",
|
||||||
|
duration: 300, // total duration in search
|
||||||
|
coverUrl: "",
|
||||||
|
platform: "bilibili",
|
||||||
|
}],
|
||||||
|
albums: [],
|
||||||
|
playlists: [],
|
||||||
|
}),
|
||||||
|
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
|
||||||
|
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
config: { commandPrefix: "!" },
|
||||||
|
lastSearchResults: [] as any[],
|
||||||
|
getProvider: () => mockBili,
|
||||||
|
getProviderFor: () => mockBili,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
|
||||||
|
name: "play",
|
||||||
|
args: "测试视频",
|
||||||
|
rawArgs: ["测试视频"],
|
||||||
|
flags: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.song).toBeDefined();
|
||||||
|
expect(res.song.id).toBe("BV1multiP?p=1");
|
||||||
|
expect(res.song.name).toBe("测试视频 - P1 分P1");
|
||||||
|
expect(res.song.duration).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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("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("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("keeps the old behavior for a bare id", async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
await cmdPlaylist.call(ctx, cmd("2829883282"));
|
||||||
|
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-7
@@ -13,7 +13,13 @@ import {
|
|||||||
canRunCommand,
|
canRunCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import {
|
||||||
|
parseSongRef,
|
||||||
|
parseSelectionIndex,
|
||||||
|
parsePlaylistRef,
|
||||||
|
findShareShortLink,
|
||||||
|
resolveShareLink,
|
||||||
|
} from "./song-ref.js";
|
||||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
|
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
|
||||||
@@ -456,11 +462,16 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.voiceDucking.removeSpeaker(event.id);
|
this.voiceDucking.removeSpeaker(event.id);
|
||||||
void this.refreshOccupancy();
|
void this.refreshOccupancy();
|
||||||
});
|
});
|
||||||
this.tsClient.on("clientMoved", (event: { id: number }) => {
|
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
|
||||||
if (event.id === this.tsClient.getClientId()) {
|
if (event.id === this.tsClient.getClientId()) {
|
||||||
// Moving the bot invalidates every activity deadline from its old
|
// Moving the bot invalidates every activity deadline from its old
|
||||||
// channel even if no individual leave events arrive.
|
// channel even if no individual leave events arrive.
|
||||||
this.voiceDucking.reset(false);
|
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 {
|
} else {
|
||||||
this.voiceDucking.removeSpeaker(event.id);
|
this.voiceDucking.removeSpeaker(event.id);
|
||||||
}
|
}
|
||||||
@@ -968,6 +979,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.voteSkipUsers.clear();
|
this.voteSkipUsers.clear();
|
||||||
const provider = this.getProviderFor(song.platform);
|
const provider = this.getProviderFor(song.platform);
|
||||||
try {
|
try {
|
||||||
|
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
|
||||||
|
const detail = await provider.getSongDetail(song.id);
|
||||||
|
if (detail) {
|
||||||
|
song.duration = detail.duration;
|
||||||
|
song.name = detail.name;
|
||||||
|
song.id = detail.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await provider.getSongUrl(song.id);
|
const result = await provider.getSongUrl(song.id);
|
||||||
if (!result?.url) {
|
if (!result?.url) {
|
||||||
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||||
@@ -1134,7 +1153,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
return { error: `No recent search. Use ${p}search <name> first.` };
|
return { error: `No recent search. Use ${p}search <name> first.` };
|
||||||
if (sel > this.lastSearchResults.length)
|
if (sel > this.lastSearchResults.length)
|
||||||
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
|
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
|
||||||
return { song: this.lastSearchResults[sel - 1] };
|
let song = this.lastSearchResults[sel - 1];
|
||||||
|
if (song.platform === "bilibili") {
|
||||||
|
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
|
||||||
|
if (detail) song = { ...detail, platform: "bilibili" };
|
||||||
|
}
|
||||||
|
return { song };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) id/URL — fetch that exact song.
|
// 2) id/URL — fetch that exact song.
|
||||||
@@ -1151,7 +1175,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
const result = await provider.search(args, 1);
|
const result = await provider.search(args, 1);
|
||||||
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
|
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
|
||||||
return { song: { ...result.songs[0], platform: provider.platform } };
|
let song = result.songs[0];
|
||||||
|
if (provider.platform === "bilibili") {
|
||||||
|
const detail = await provider.getSongDetail(song.id);
|
||||||
|
if (detail) song = detail;
|
||||||
|
}
|
||||||
|
return { song: { ...song, platform: provider.platform } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
||||||
@@ -1495,8 +1524,21 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
||||||
const id = this.extractId(cmd.args);
|
const id = this.extractId(cmd.args);
|
||||||
@@ -1504,7 +1546,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
let playlistId: string;
|
let playlistId: string;
|
||||||
|
|
||||||
if (isDirectId || id !== cmd.args) {
|
if (ref) {
|
||||||
|
playlistId = ref.id;
|
||||||
|
} else if (isDirectId || id !== cmd.args) {
|
||||||
// Input is a direct ID or URL containing an ID — use existing logic
|
// Input is a direct ID or URL containing an ID — use existing logic
|
||||||
playlistId = id;
|
playlistId = id;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -202,3 +202,65 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
|
|||||||
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+47
-5
@@ -32,6 +32,13 @@ export class BotProfileManager {
|
|||||||
* pushed immediately (idle) or wait for the next stop event (playing).
|
* pushed immediately (idle) or wait for the next stop event (playing).
|
||||||
*/
|
*/
|
||||||
private currentSong: QueuedSong | null = null;
|
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. */
|
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||||
private permDenied = {
|
private permDenied = {
|
||||||
@@ -134,6 +141,8 @@ export class BotProfileManager {
|
|||||||
onConnect(): void {
|
onConnect(): void {
|
||||||
this.generation++;
|
this.generation++;
|
||||||
this.currentSong = null;
|
this.currentSong = null;
|
||||||
|
// Channel ids are per-server; never carry one across a (re)connect.
|
||||||
|
this.channelDescCid = null;
|
||||||
this.permDenied = {
|
this.permDenied = {
|
||||||
avatar: false,
|
avatar: false,
|
||||||
description: false,
|
description: false,
|
||||||
@@ -150,6 +159,31 @@ 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 {
|
getConfig(): ProfileConfig {
|
||||||
return { ...this.config };
|
return { ...this.config };
|
||||||
}
|
}
|
||||||
@@ -400,18 +434,25 @@ export class BotProfileManager {
|
|||||||
return str.slice(0, end) + ellipsis;
|
return str.slice(0, end) + ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
private async updateChannelDescription(
|
||||||
|
song: QueuedSong | null,
|
||||||
|
targetChannelId?: bigint,
|
||||||
|
): Promise<void> {
|
||||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||||
try {
|
try {
|
||||||
const channelId = this.tsClient.getChannelId();
|
const channelId = targetChannelId ?? this.tsClient.getChannelId();
|
||||||
if (channelId === 0n) return; // unknown channel
|
|
||||||
|
|
||||||
if (!song) {
|
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(
|
await this.tsClient.sendCommandNoWait(
|
||||||
`channeledit cid=${channelId} channel_description=`,
|
`channeledit cid=${target} channel_description=`,
|
||||||
);
|
);
|
||||||
|
this.channelDescCid = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (channelId === 0n) return; // unknown channel
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||||
@@ -422,6 +463,7 @@ export class BotProfileManager {
|
|||||||
await this.tsClient.sendCommandNoWait(
|
await this.tsClient.sendCommandNoWait(
|
||||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||||
);
|
);
|
||||||
|
this.channelDescCid = channelId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("channelDesc", err);
|
this.handleFeatureError("channelDesc", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
|
||||||
|
|
||||||
describe("parseSongRef (#90 exact-song selection)", () => {
|
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||||
it("returns null for a plain search term", () => {
|
it("returns null for a plain search term", () => {
|
||||||
@@ -120,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
|
|||||||
expect(parseSelectionIndex("")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
* 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
|
* so same-name songs can be disambiguated instead of always getting the single
|
||||||
@@ -98,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
|
|||||||
const n = parseInt(m[1], 10);
|
const n = parseInt(m[1], 10);
|
||||||
return Number.isFinite(n) && n > 0 ? n : null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,3 +345,36 @@ describe("guest principal migration", () => {
|
|||||||
rmSync(dir, { recursive: true, force: true });
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -144,6 +144,10 @@ export interface BotDatabase {
|
|||||||
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||||
getFavorites(userId: string): FavoritePlaylist[];
|
getFavorites(userId: string): FavoritePlaylist[];
|
||||||
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
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.
|
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
|
||||||
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
||||||
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
||||||
@@ -328,6 +332,17 @@ function initTables(db: Database.Database): void {
|
|||||||
fmPlatform TEXT NOT NULL DEFAULT '',
|
fmPlatform TEXT NOT NULL DEFAULT '',
|
||||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- A web user's own music-platform login (#164), used for their personal
|
||||||
|
-- FM instead of the bot's shared account. Secret: never sent to clients.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_music_cookies (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
cookie TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (userId, platform),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +468,17 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
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
|
// A corrupt/hand-edited songs blob must never throw into a route or the
|
||||||
// restore path — degrade to an empty list instead.
|
// restore path — degrade to an empty list instead.
|
||||||
const parseSongs = (raw: string): StoredSong[] => {
|
const parseSongs = (raw: string): StoredSong[] => {
|
||||||
@@ -634,6 +660,19 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return row !== undefined;
|
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) {
|
saveQueue(ownerId, name, songs) {
|
||||||
if (songs.length > MAX_QUEUE_SONGS) {
|
if (songs.length > MAX_QUEUE_SONGS) {
|
||||||
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
||||||
|
|||||||
@@ -37,3 +37,128 @@ describe("BiliBiliProvider.search pagination", () => {
|
|||||||
expect(searchParams(get).page).toBe("1");
|
expect(searchParams(get).page).toBe("1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BiliBiliProvider multi-P support", () => {
|
||||||
|
it("parseBilibiliId extracts bvid and page correctly", async () => {
|
||||||
|
const { parseBilibiliId } = await import("./bilibili.js");
|
||||||
|
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
|
||||||
|
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
|
||||||
|
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
|
||||||
|
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
|
||||||
|
bvid: "BV1yxHQeYEuE",
|
||||||
|
page: 5,
|
||||||
|
});
|
||||||
|
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockViewProvider(viewData: any, playUrlData?: any) {
|
||||||
|
const p = new BiliBiliProvider();
|
||||||
|
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
|
||||||
|
if (url === "/x/web-interface/view") {
|
||||||
|
return Promise.resolve({ data: { data: viewData } });
|
||||||
|
}
|
||||||
|
if (url === "/x/player/playurl") {
|
||||||
|
return Promise.resolve({ data: { data: playUrlData ?? {} } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} });
|
||||||
|
});
|
||||||
|
(p as any).buvidInitialized = true;
|
||||||
|
(p as any).api = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiPViewData = {
|
||||||
|
bvid: "BV1multiP",
|
||||||
|
title: "测试多P教程",
|
||||||
|
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
|
||||||
|
duration: 300, // 总时长 300 秒 (120 + 180)
|
||||||
|
owner: { name: "UP主测试" },
|
||||||
|
pages: [
|
||||||
|
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
|
||||||
|
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const singlePViewData = {
|
||||||
|
bvid: "BV1singleP",
|
||||||
|
title: "测试单P视频",
|
||||||
|
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
|
||||||
|
duration: 200,
|
||||||
|
owner: { name: "UP主测试" },
|
||||||
|
pages: [
|
||||||
|
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
|
||||||
|
const { p } = mockViewProvider(singlePViewData);
|
||||||
|
const song = await p.getSongDetail("BV1singleP");
|
||||||
|
expect(song).not.toBeNull();
|
||||||
|
expect(song!.id).toBe("BV1singleP");
|
||||||
|
expect(song!.name).toBe("测试单P视频");
|
||||||
|
expect(song!.duration).toBe(200);
|
||||||
|
expect(song!.platform).toBe("bilibili");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
|
||||||
|
const { p } = mockViewProvider(multiPViewData);
|
||||||
|
const song = await p.getSongDetail("BV1multiP");
|
||||||
|
expect(song).not.toBeNull();
|
||||||
|
expect(song!.id).toBe("BV1multiP?p=1");
|
||||||
|
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
|
||||||
|
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
|
||||||
|
expect(song!.platform).toBe("bilibili");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
|
||||||
|
const { p } = mockViewProvider(multiPViewData);
|
||||||
|
const song = await p.getSongDetail("BV1multiP?p=2");
|
||||||
|
expect(song).not.toBeNull();
|
||||||
|
expect(song!.id).toBe("BV1multiP?p=2");
|
||||||
|
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
|
||||||
|
expect(song!.duration).toBe(180); // P2 独立时长
|
||||||
|
expect(song!.platform).toBe("bilibili");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getVideoParts returns all parts with duration and cid", async () => {
|
||||||
|
const { p } = mockViewProvider(multiPViewData);
|
||||||
|
const partsResult = await p.getVideoParts("BV1multiP");
|
||||||
|
expect(partsResult).not.toBeNull();
|
||||||
|
expect(partsResult!.bvid).toBe("BV1multiP");
|
||||||
|
expect(partsResult!.title).toBe("测试多P教程");
|
||||||
|
expect(partsResult!.parts).toHaveLength(2);
|
||||||
|
expect(partsResult!.parts[0]).toEqual({
|
||||||
|
part: 1,
|
||||||
|
cid: 10001,
|
||||||
|
title: "第一讲 入门",
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
expect(partsResult!.parts[1]).toEqual({
|
||||||
|
part: 2,
|
||||||
|
cid: 10002,
|
||||||
|
title: "第二讲 进阶",
|
||||||
|
duration: 180,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSongUrl requests playurl with correct cid for specific part", async () => {
|
||||||
|
const playUrlResponse = {
|
||||||
|
dash: {
|
||||||
|
audio: [
|
||||||
|
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
|
||||||
|
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
|
||||||
|
|
||||||
|
const result = await p.getSongUrl("BV1multiP?p=2");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.url).toBe("http://audio.320k.test");
|
||||||
|
|
||||||
|
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
|
||||||
|
expect(playurlCall).toBeTruthy();
|
||||||
|
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
|
||||||
|
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
-11
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
|
|||||||
|
|
||||||
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
|
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
|
||||||
|
|
||||||
|
export interface BiliVideoPart {
|
||||||
|
part: number;
|
||||||
|
cid: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BiliVideoPartsResult {
|
||||||
|
bvid: string;
|
||||||
|
title: string;
|
||||||
|
coverUrl: string;
|
||||||
|
artist: string;
|
||||||
|
parts: BiliVideoPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析带有分P信息的 B站 ID 或 URL。
|
||||||
|
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
||||||
|
*/
|
||||||
|
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
|
||||||
|
const str = (songId ?? "").trim();
|
||||||
|
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
|
||||||
|
if (!bvMatch) {
|
||||||
|
return { bvid: str, page: 1 };
|
||||||
|
}
|
||||||
|
const bvid = bvMatch[0];
|
||||||
|
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
|
||||||
|
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
|
||||||
|
const page = pageStr ? parseInt(pageStr, 10) : 1;
|
||||||
|
return { bvid, page: Math.max(1, page) };
|
||||||
|
}
|
||||||
|
|
||||||
export class BiliBiliProvider implements MusicProvider {
|
export class BiliBiliProvider implements MusicProvider {
|
||||||
readonly platform = "bilibili" as const;
|
readonly platform = "bilibili" as const;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -192,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSongDetail(songId: string): Promise<Song | null> {
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
|
const { bvid, page } = parseBilibiliId(songId);
|
||||||
try {
|
try {
|
||||||
const res = await this.api.get("/x/web-interface/view", {
|
const res = await this.api.get("/x/web-interface/view", {
|
||||||
params: { bvid: songId },
|
params: { bvid },
|
||||||
headers: this.cookieHeaders,
|
headers: this.cookieHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = res.data?.data;
|
const data = res.data?.data;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
// Cache cid for later audio URL fetching
|
const pages = data.pages ?? [];
|
||||||
if (data.pages?.[0]?.cid) {
|
// 缓存所有分P的 cid 映射
|
||||||
this.cidCache.set(songId, data.pages[0].cid);
|
for (const p of pages) {
|
||||||
|
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
|
||||||
|
}
|
||||||
|
if (pages[0]?.cid) {
|
||||||
|
this.cidCache.set(bvid, pages[0].cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
|
||||||
|
|
||||||
|
// 若为多P视频,返回对应分P的名称与独立时长
|
||||||
|
if (pages.length > 1 && targetPage) {
|
||||||
|
const partTitle = targetPage.part && targetPage.part !== data.title
|
||||||
|
? `${data.title} - P${targetPage.page} ${targetPage.part}`
|
||||||
|
: `${data.title} (P${targetPage.page})`;
|
||||||
|
return {
|
||||||
|
id: `${bvid}?p=${targetPage.page}`,
|
||||||
|
name: partTitle,
|
||||||
|
artist: data.owner?.name ?? "",
|
||||||
|
album: "",
|
||||||
|
duration: targetPage.duration ?? 0,
|
||||||
|
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||||
|
platform: "bilibili" as const,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -211,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
name: data.title ?? "",
|
name: data.title ?? "",
|
||||||
artist: data.owner?.name ?? "",
|
artist: data.owner?.name ?? "",
|
||||||
album: "",
|
album: "",
|
||||||
duration: data.duration ?? 0,
|
duration: targetPage?.duration ?? data.duration ?? 0,
|
||||||
coverUrl: this.normalizeCover(data.pic ?? ""),
|
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||||
platform: "bilibili" as const,
|
platform: "bilibili" as const,
|
||||||
};
|
};
|
||||||
@@ -220,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取视频所有分P列表 */
|
||||||
|
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
|
||||||
|
const { bvid: cleanBvid } = parseBilibiliId(bvid);
|
||||||
|
try {
|
||||||
|
const res = await this.api.get("/x/web-interface/view", {
|
||||||
|
params: { bvid: cleanBvid },
|
||||||
|
headers: this.cookieHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = res.data?.data;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const pages = data.pages ?? [];
|
||||||
|
for (const p of pages) {
|
||||||
|
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
|
||||||
|
}
|
||||||
|
if (pages[0]?.cid) {
|
||||||
|
this.cidCache.set(cleanBvid, pages[0].cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bvid: cleanBvid,
|
||||||
|
title: data.title ?? "",
|
||||||
|
coverUrl: this.normalizeCover(data.pic ?? ""),
|
||||||
|
artist: data.owner?.name ?? "",
|
||||||
|
parts: pages.map((p: any) => ({
|
||||||
|
part: p.page,
|
||||||
|
cid: p.cid,
|
||||||
|
title: p.part ?? `P${p.page}`,
|
||||||
|
duration: p.duration ?? 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Get CID for a bvid, using cache when available */
|
/** Get CID for a bvid, using cache when available */
|
||||||
private async getCid(bvid: string): Promise<number | null> {
|
private async getCid(bvid: string, page = 1): Promise<number | null> {
|
||||||
const cached = this.cidCache.get(bvid);
|
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
|
||||||
|
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// Limit cache size to prevent unbounded growth
|
// Limit cache size to prevent unbounded growth
|
||||||
@@ -231,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
if (firstKey) this.cidCache.delete(firstKey);
|
if (firstKey) this.cidCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = await this.getSongDetail(bvid);
|
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
|
||||||
|
const detail = await this.getSongDetail(songId);
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
return this.cidCache.get(bvid) ?? null;
|
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
||||||
const cid = await this.getCid(songId);
|
const { bvid, page } = parseBilibiliId(songId);
|
||||||
|
const cid = await this.getCid(bvid, page);
|
||||||
if (!cid) return null;
|
if (!cid) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.api.get("/x/player/playurl", {
|
const res = await this.api.get("/x/player/playurl", {
|
||||||
params: {
|
params: {
|
||||||
cid,
|
cid,
|
||||||
bvid: songId,
|
bvid,
|
||||||
fnval: 16, // DASH format
|
fnval: 16, // DASH format
|
||||||
},
|
},
|
||||||
headers: this.cookieHeaders,
|
headers: this.cookieHeaders,
|
||||||
|
|||||||
@@ -139,3 +139,48 @@ describe("NeteaseProvider.search pagination", () => {
|
|||||||
expect(callByType(get, 10).offset).toBe(0);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-9
@@ -111,8 +111,10 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie = "";
|
private cookie = "";
|
||||||
private quality = "exhigh";
|
private quality = "exhigh";
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -243,25 +245,47 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
async checkQrCodeStatus(
|
async checkQrCodeStatus(
|
||||||
key: string
|
key: string
|
||||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
): 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", {
|
const res = await this.api.get("/login/qr/check", {
|
||||||
params: { key, timestamp: Date.now() },
|
params: { key, timestamp: Date.now() },
|
||||||
});
|
});
|
||||||
const code = res.data?.code;
|
switch (res.data?.code) {
|
||||||
switch (code) {
|
|
||||||
case 801:
|
case 801:
|
||||||
return "waiting";
|
return { status: "waiting" };
|
||||||
case 802:
|
case 802:
|
||||||
return "scanned";
|
return { status: "scanned" };
|
||||||
case 803:
|
case 803:
|
||||||
if (res.data?.cookie) {
|
return res.data?.cookie
|
||||||
this.cookie = res.data.cookie;
|
? { status: "confirmed", cookie: res.data.cookie }
|
||||||
}
|
: { status: "confirmed" };
|
||||||
return "confirmed";
|
|
||||||
default:
|
default:
|
||||||
return "expired";
|
return { status: "expired" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider for the same API server logged in as another account (#164):
|
||||||
|
* a web user's personal FM uses their own taste instead of the shared login.
|
||||||
|
*/
|
||||||
|
withCookie(cookie: string): NeteaseProvider {
|
||||||
|
const view = new NeteaseProvider(this.baseUrl);
|
||||||
|
view.setQuality(this.quality);
|
||||||
|
view.setCookie(cookie);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
async sendSmsCode(phone: string): Promise<boolean> {
|
async sendSmsCode(phone: string): Promise<boolean> {
|
||||||
const res = await this.api.get("/captcha/sent", {
|
const res = await this.api.get("/captcha/sent", {
|
||||||
params: { phone },
|
params: { phone },
|
||||||
|
|||||||
@@ -438,3 +438,68 @@ describe("music router POST /local/upload — content types and size cap (#149)"
|
|||||||
expect(uploadAudio).not.toHaveBeenCalled();
|
expect(uploadAudio).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("music router GET /bilibili/parts", () => {
|
||||||
|
it("returns 400 when bvid is missing", async () => {
|
||||||
|
const router = createMusicRouter(
|
||||||
|
fakeProvider("netease"),
|
||||||
|
fakeProvider("qq"),
|
||||||
|
fakeProvider("bilibili"),
|
||||||
|
pino({ level: "silent" })
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use("/api/music", router);
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/music/bilibili/parts");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("bvid is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns parts from bilibili provider when available", async () => {
|
||||||
|
const mockBilibili = {
|
||||||
|
platform: "bilibili" as const,
|
||||||
|
search: vi.fn(),
|
||||||
|
getVideoParts: vi.fn().mockResolvedValue({
|
||||||
|
bvid: "BV1test",
|
||||||
|
title: "多P视频测试",
|
||||||
|
parts: [
|
||||||
|
{ part: 1, cid: 101, title: "P1", duration: 100 },
|
||||||
|
{ part: 2, cid: 102, title: "P2", duration: 200 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const router = createMusicRouter(
|
||||||
|
fakeProvider("netease"),
|
||||||
|
fakeProvider("qq"),
|
||||||
|
mockBilibili as unknown as MusicProvider,
|
||||||
|
pino({ level: "silent" })
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use("/api/music", router);
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.bvid).toBe("BV1test");
|
||||||
|
expect(res.body.parts).toHaveLength(2);
|
||||||
|
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when getVideoParts returns null", async () => {
|
||||||
|
const mockBilibili = {
|
||||||
|
platform: "bilibili" as const,
|
||||||
|
search: vi.fn(),
|
||||||
|
getVideoParts: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
const router = createMusicRouter(
|
||||||
|
fakeProvider("netease"),
|
||||||
|
fakeProvider("qq"),
|
||||||
|
mockBilibili as unknown as MusicProvider,
|
||||||
|
pino({ level: "silent" })
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use("/api/music", router);
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -382,6 +382,31 @@ export function createMusicRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// B站分P列表查询
|
||||||
|
router.get("/bilibili/parts", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bvid = (req.query.bvid as string)?.trim();
|
||||||
|
if (!bvid) {
|
||||||
|
res.status(400).json({ error: "bvid is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = bilibiliProvider as any;
|
||||||
|
if (typeof provider.getVideoParts === "function") {
|
||||||
|
const result = await provider.getVideoParts(bvid);
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ error: "Video not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(501).json({ error: "Not supported" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Get bilibili parts failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Enabled sources + default platform, for the web UI (source tabs, default
|
// Enabled sources + default platform, for the web UI (source tabs, default
|
||||||
// search/playback source). Without a config (unit-test routers) everything
|
// search/playback source). Without a config (unit-test routers) everything
|
||||||
// reports enabled with the legacy netease default.
|
// reports enabled with the legacy netease default.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase } from "../../data/database.js";
|
||||||
|
import { createPersonalMusicRouter } from "./personal-music.js";
|
||||||
|
import { createPlayerRouter } from "./player.js";
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
db.db
|
||||||
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run("u1", "alice", "x", 0, 0, "member");
|
||||||
|
const personalView = {
|
||||||
|
getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })),
|
||||||
|
};
|
||||||
|
const provider: any = {
|
||||||
|
platform: "netease",
|
||||||
|
getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })),
|
||||||
|
pollQrLogin: vi.fn(async () => ({ status: "waiting" })),
|
||||||
|
withCookie: vi.fn(() => personalView),
|
||||||
|
setCookie: vi.fn(),
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).user = { id: "u1", username: "alice", role: "member" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" })));
|
||||||
|
return { app, db, provider, personalView };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("personal music account router (#164)", () => {
|
||||||
|
it("reports not linked until the user logs in", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
const res = await request(app).get("/api/me/music/netease/status");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ linked: false, loggedIn: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a QR code", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
const res = await request(app).post("/api/me/music/netease/qrcode");
|
||||||
|
expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => {
|
||||||
|
const { app, db, provider } = mount();
|
||||||
|
provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" });
|
||||||
|
const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" });
|
||||||
|
expect(res.body).toEqual({ status: "confirmed" });
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain("MUSIC_U");
|
||||||
|
expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice");
|
||||||
|
expect(provider.setCookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a key to poll", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the linked account's nickname via a view on the user's cookie", async () => {
|
||||||
|
const { app, db, provider } = mount();
|
||||||
|
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
const res = await request(app).get("/api/me/music/netease/status");
|
||||||
|
expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" });
|
||||||
|
expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinks", async () => {
|
||||||
|
const { app, db } = mount();
|
||||||
|
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
expect((await request(app).delete("/api/me/music/netease")).status).toBe(200);
|
||||||
|
expect(db.getUserMusicCookie("u1", "netease")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("web FM uses the caller's linked NetEase account (#164)", () => {
|
||||||
|
async function startFm(opts: { linked: boolean; role?: string; platform?: string }) {
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
db.db
|
||||||
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run("u1", "alice", "x", 0, 0, "member");
|
||||||
|
if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
const personal = { platform: "netease", personal: true };
|
||||||
|
const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) };
|
||||||
|
const qq: any = { platform: "qq" };
|
||||||
|
const bot = {
|
||||||
|
id: "b1",
|
||||||
|
getProviderFor: (p: string) => (p === "qq" ? qq : shared),
|
||||||
|
startFm: vi.fn(async (_provider: unknown) => "Personal FM started"),
|
||||||
|
};
|
||||||
|
const botManager: any = { getBot: () => bot };
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).user = {
|
||||||
|
id: "u1", username: "alice", role: opts.role ?? "member",
|
||||||
|
capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true },
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db));
|
||||||
|
const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" });
|
||||||
|
return { res, bot, shared, personal, qq };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts FM on the user's own account when linked", async () => {
|
||||||
|
const { res, bot, shared, personal } = await startFm({ linked: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(personal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the shared account when the user has not linked one", async () => {
|
||||||
|
const { bot, shared } = await startFm({ linked: false });
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(shared);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other platforms alone", async () => {
|
||||||
|
const { bot, qq } = await startFm({ linked: true, platform: "qq" });
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(qq);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { MusicProvider, QrCodeResult } from "../../music/provider.js";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider that can log a web user into their OWN account without touching
|
||||||
|
* the bot's shared login, and hand out a view bound to that account (#164).
|
||||||
|
*/
|
||||||
|
export interface PersonalLoginProvider {
|
||||||
|
getQrCode(): Promise<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;
|
||||||
|
}
|
||||||
+10
-1
@@ -6,6 +6,7 @@ import type { Logger } from "../../logger.js";
|
|||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
import { requireBotAccess } from "../middleware/requirePermission.js";
|
import { requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
import { authorize } from "../middleware/authorize.js";
|
import { authorize } from "../middleware/authorize.js";
|
||||||
|
import { supportsPersonalLogin } from "./personal-music.js";
|
||||||
|
|
||||||
export function createPlayerRouter(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
@@ -124,11 +125,19 @@ export function createPlayerRouter(
|
|||||||
rejectDisabledLocalAudio(res);
|
rejectDisabledLocalAudio(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = bot.getProviderFor(
|
let provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "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));
|
const message = await bot.startFm(provider, requesterName(req));
|
||||||
res.json({
|
res.json({
|
||||||
ok:
|
ok:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js";
|
|||||||
import { createAuditStore } from "../data/audit.js";
|
import { createAuditStore } from "../data/audit.js";
|
||||||
import { createAuditRouter } from "./api/audit.js";
|
import { createAuditRouter } from "./api/audit.js";
|
||||||
import { createFavoritesRouter } from "./api/favorites.js";
|
import { createFavoritesRouter } from "./api/favorites.js";
|
||||||
|
import { createPersonalMusicRouter } from "./api/personal-music.js";
|
||||||
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||||||
import { createSpotifyRouter } from "./api/spotify.js";
|
import { createSpotifyRouter } from "./api/spotify.js";
|
||||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||||
@@ -203,6 +204,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
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);
|
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
|
||||||
// the router itself 403s every route unless savedQueuesEnabled is on.
|
// the router itself 403s every route unless savedQueuesEnabled is on.
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<Player />
|
<Player />
|
||||||
<Toast />
|
<Toast />
|
||||||
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
||||||
|
<BilibiliPartModal />
|
||||||
|
|
||||||
<!-- Mobile mini player -->
|
<!-- Mobile mini player -->
|
||||||
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
||||||
@@ -102,6 +103,7 @@ import Player from './components/Player.vue';
|
|||||||
import CoverArt from './components/CoverArt.vue';
|
import CoverArt from './components/CoverArt.vue';
|
||||||
import Toast from './components/Toast.vue';
|
import Toast from './components/Toast.vue';
|
||||||
import Queue from './components/Queue.vue';
|
import Queue from './components/Queue.vue';
|
||||||
|
import BilibiliPartModal from './components/BilibiliPartModal.vue';
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
|
||||||
|
<div class="edit-modal">
|
||||||
|
<h3 class="modal-title">选择分P</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>视频名称</label>
|
||||||
|
<div class="video-info-box">
|
||||||
|
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
|
||||||
|
<div class="video-meta">
|
||||||
|
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
|
||||||
|
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>分P列表</label>
|
||||||
|
<div class="parts-list">
|
||||||
|
<div
|
||||||
|
v-for="part in modal.parts"
|
||||||
|
:key="part.part"
|
||||||
|
class="part-item"
|
||||||
|
:class="{ active: selectedPart?.part === part.part }"
|
||||||
|
@click="selectedPart = part"
|
||||||
|
@dblclick="confirmSelect(part)"
|
||||||
|
>
|
||||||
|
<span class="part-badge">P{{ part.part }}</span>
|
||||||
|
<span class="part-name" :title="part.title">{{ part.title }}</span>
|
||||||
|
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!selectedPart"
|
||||||
|
@click="selectedPart && confirmSelect(selectedPart)"
|
||||||
|
>
|
||||||
|
{{ confirmBtnText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { usePlayerStore, type BiliPart } from '../stores/player.js';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const modal = computed(() => store.biliPartModal);
|
||||||
|
|
||||||
|
const selectedPart = ref<BiliPart | null>(null);
|
||||||
|
|
||||||
|
// 弹窗打开时默认选中第 1 P
|
||||||
|
watch(
|
||||||
|
() => modal.value.open,
|
||||||
|
(open) => {
|
||||||
|
if (open && modal.value.parts.length > 0) {
|
||||||
|
selectedPart.value = modal.value.parts[0];
|
||||||
|
} else {
|
||||||
|
selectedPart.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionHint = computed(() => {
|
||||||
|
if (modal.value.action === 'playNext') return '添加到下一首播放';
|
||||||
|
if (modal.value.action === 'add') return '添加到播放队列';
|
||||||
|
return '立即播放';
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmBtnText = computed(() => {
|
||||||
|
if (modal.value.action === 'playNext') return '下一首播放';
|
||||||
|
if (modal.value.action === 'add') return '添加到队列';
|
||||||
|
return '播放';
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirmSelect(part: BiliPart) {
|
||||||
|
store.selectBilibiliPart(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (!seconds || seconds <= 0) return '--:--';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.edit-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 28px;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
.video-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.parts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
|
||||||
|
|
||||||
|
.part-badge {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-name {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<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>
|
||||||
+107
-3
@@ -17,6 +17,24 @@ export interface Song {
|
|||||||
|
|
||||||
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||||
|
|
||||||
|
export interface BiliPart {
|
||||||
|
part: number;
|
||||||
|
cid: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BiliPartModalState {
|
||||||
|
open: boolean;
|
||||||
|
song: Song | null;
|
||||||
|
action: 'play' | 'playNext' | 'add';
|
||||||
|
bvid: string;
|
||||||
|
title: string;
|
||||||
|
coverUrl: string;
|
||||||
|
artist: string;
|
||||||
|
parts: BiliPart[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlbumItem {
|
export interface AlbumItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
// Transient notification for surfacing failures (e.g., "song not playable")
|
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||||
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||||
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||||
|
|
||||||
|
// Bilibili 多P分P选择弹窗状态
|
||||||
|
biliPartModal: {
|
||||||
|
open: false,
|
||||||
|
song: null,
|
||||||
|
action: 'play',
|
||||||
|
bvid: '',
|
||||||
|
title: '',
|
||||||
|
coverUrl: '',
|
||||||
|
artist: '',
|
||||||
|
parts: [] as BiliPart[],
|
||||||
|
} as BiliPartModalState,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.notification = { id: Date.now(), message, type };
|
this.notification = { id: Date.now(), message, type };
|
||||||
},
|
},
|
||||||
|
|
||||||
async playSong(song: Song) {
|
/**
|
||||||
|
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
|
||||||
|
*/
|
||||||
|
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const cleanBvid = song.id.split('?')[0].split(':')[0];
|
||||||
|
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
|
||||||
|
const parts: BiliPart[] = res.data?.parts ?? [];
|
||||||
|
if (parts.length > 1) {
|
||||||
|
this.biliPartModal = {
|
||||||
|
open: true,
|
||||||
|
song,
|
||||||
|
action,
|
||||||
|
bvid: cleanBvid,
|
||||||
|
title: res.data.title || song.name,
|
||||||
|
coverUrl: res.data.coverUrl || song.coverUrl,
|
||||||
|
artist: res.data.artist || song.artist,
|
||||||
|
parts,
|
||||||
|
};
|
||||||
|
return true; // 弹窗接管
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
song.duration = parts[0].duration;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络请求异常则降级为正常播放
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectBilibiliPart(part: BiliPart) {
|
||||||
|
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
|
||||||
|
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
|
||||||
|
this.biliPartModal.open = false;
|
||||||
|
|
||||||
|
const partTitle = part.title && part.title !== title
|
||||||
|
? `${title} - P${part.part} ${part.title}`
|
||||||
|
: `${title} (P${part.part})`;
|
||||||
|
|
||||||
|
const partSong: Song = {
|
||||||
|
...song,
|
||||||
|
id: `${bvid}?p=${part.part}`,
|
||||||
|
name: partTitle,
|
||||||
|
artist: artist || song.artist,
|
||||||
|
coverUrl: coverUrl || song.coverUrl,
|
||||||
|
duration: part.duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === 'play') {
|
||||||
|
this.playSong(partSong, true);
|
||||||
|
} else if (action === 'playNext') {
|
||||||
|
this.playNextSong(partSong, true);
|
||||||
|
} else if (action === 'add') {
|
||||||
|
this.addSong(partSong, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeBilibiliPartModal() {
|
||||||
|
this.biliPartModal.open = false;
|
||||||
|
this.biliPartModal.song = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async playSong(song: Song, skipPartCheck = false) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
|
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||||
|
const handled = await this.checkBilibiliMultiPart(song, 'play');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
// Guests use the non-destructive "play now" (insert-next + skip) so they
|
// Guests use the non-destructive "play now" (insert-next + skip) so they
|
||||||
// can't wipe everyone else's queue; members/admins keep the normal behavior.
|
// can't wipe everyone else's queue; members/admins keep the normal behavior.
|
||||||
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
|
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
|
||||||
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async playNextSong(song: Song) {
|
async playNextSong(song: Song, skipPartCheck = false) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
|
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||||
|
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
|
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
|
||||||
if (res.data?.message) {
|
if (res.data?.message) {
|
||||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||||
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
||||||
},
|
},
|
||||||
|
|
||||||
async addSong(song: Song) {
|
async addSong(song: Song, skipPartCheck = false) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
|
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||||
|
const handled = await this.checkBilibiliMultiPart(song, 'add');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||||
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||||
|
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||||
@@ -1161,6 +1162,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import AvatarUpload from '../components/AvatarUpload.vue';
|
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||||
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
||||||
|
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
import { useSession } from '../composables/useSession.js';
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
|||||||
Reference in new issue
Block a user