mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87fca6d8b7 | ||
|
|
c7b1268281 | ||
|
|
b942a4726e | ||
|
|
ad728a3a04 | ||
|
|
c51d311ab6 | ||
|
|
88e6b5a691 | ||
|
|
6b82df4e50 | ||
|
|
faf6ac09ec |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -1590,3 +1590,70 @@ describe("BotInstance Bilibili multi-P resolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("cmdPlaylist with a playlist link (#160)", () => {
|
||||||
|
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
|
||||||
|
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
-5
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -1513,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);
|
||||||
@@ -1522,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user