mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e6b5a691 |
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
|
||||||
|
```
|
||||||
|
|
||||||
## 更新升级
|
## 更新升级
|
||||||
|
|
||||||
@@ -495,6 +516,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
|
||||||
|
|||||||
+63
-21
@@ -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
|
||||||
|
case $OS in
|
||||||
|
ubuntu|debian)
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
|
||||||
|
sudo apt-get install -y -qq nodejs
|
||||||
|
;;
|
||||||
|
centos|rhel|fedora|rocky|almalinux)
|
||||||
|
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
|
||||||
|
sudo yum install -y nodejs
|
||||||
|
;;
|
||||||
|
arch|manjaro)
|
||||||
|
sudo pacman -S --noconfirm --needed nodejs npm
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if ! node_supported; then
|
||||||
|
echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)."
|
||||||
|
echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Node.js $(node -v) installed"
|
echo "Node.js $(node -v) installed"
|
||||||
|
|
||||||
echo "[3/6] Installing dependencies..."
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
npm install
|
|
||||||
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
|
|
||||||
(cd "$PROJECT_DIR/web" && npm install)
|
|
||||||
fi
|
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,7 +141,7 @@ 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 "╔══════════════════════════════════════╗"
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ describe("buildFfmpegArgs", () => {
|
|||||||
expect(ssIdx).toBeGreaterThan(iIdx);
|
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
|
|
||||||
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
|
|
||||||
const ssIdx = args.indexOf("-ss");
|
|
||||||
expect(args[ssIdx + 1]).toBe("3600");
|
|
||||||
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
|
|
||||||
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not insert -ss when seekSeconds is 0", () => {
|
it("does not insert -ss when seekSeconds is 0", () => {
|
||||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||||
expect(args).not.toContain("-ss");
|
expect(args).not.toContain("-ss");
|
||||||
|
|||||||
+2
-9
@@ -76,9 +76,8 @@ export function cleanupTempDir(dir: string): void {
|
|||||||
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
const isHttp = /^https?:\/\//i.test(url);
|
const isHttp = /^https?:\/\//i.test(url);
|
||||||
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
|
|
||||||
|
|
||||||
if (isBilibili) {
|
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
|
||||||
args.push(
|
args.push(
|
||||||
"-headers",
|
"-headers",
|
||||||
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||||
@@ -104,15 +103,9 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
"-reconnect_on_http_error", "4xx,5xx",
|
"-reconnect_on_http_error", "4xx,5xx",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
|
|
||||||
// to the byte offset. Output-side seek would download and decode everything
|
|
||||||
// before the target first — minutes for a resume deep into a 3-hour video
|
|
||||||
// (#161), long enough to trip the stall watchdog.
|
|
||||||
const inputSideSeek = isBilibili;
|
|
||||||
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
|
|
||||||
args.push("-i", url);
|
args.push("-i", url);
|
||||||
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
||||||
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
|
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||||
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
|
|||||||
@@ -1590,97 +1590,3 @@ describe("BotInstance Bilibili multi-P resolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => {
|
|
||||||
const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as (
|
|
||||||
this: unknown,
|
|
||||||
) => Promise<boolean>;
|
|
||||||
|
|
||||||
function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) {
|
|
||||||
const song: any = {
|
|
||||||
id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "",
|
|
||||||
platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old",
|
|
||||||
};
|
|
||||||
let elapsed = opts.elapsed ?? 1000;
|
|
||||||
let state: "idle" | "playing" = "idle";
|
|
||||||
const provider = {
|
|
||||||
getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })),
|
|
||||||
};
|
|
||||||
const ctx: any = {
|
|
||||||
song,
|
|
||||||
provider,
|
|
||||||
connected: true,
|
|
||||||
effectiveDuration: song.duration,
|
|
||||||
streamRecovery: null,
|
|
||||||
queue: { current: vi.fn(() => song) },
|
|
||||||
player: {
|
|
||||||
getElapsed: vi.fn(() => elapsed),
|
|
||||||
getState: vi.fn(() => state),
|
|
||||||
play: vi.fn(() => { state = "playing"; }),
|
|
||||||
},
|
|
||||||
getProviderFor: vi.fn(() => provider),
|
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
|
||||||
emit: vi.fn(),
|
|
||||||
setElapsed: (v: number) => { elapsed = v; state = "idle"; },
|
|
||||||
};
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000, duration: 10_000 });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc");
|
|
||||||
expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000);
|
|
||||||
expect(ctx.song.url).toBe("https://fresh.test/a.m4s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing near the real end of the track (normal EOF)", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.provider.getSongUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing for other platforms or an unknown duration", async () => {
|
|
||||||
expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false);
|
|
||||||
const unknown = makeCtx({ duration: 0 });
|
|
||||||
unknown.effectiveDuration = 0;
|
|
||||||
expect(await resumeInterruptedStream.call(unknown)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives up after 3 attempts that make no progress, then lets the queue advance", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000 });
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
}
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.player.play).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the attempt budget once a resume actually plays on for a while", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000 });
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
await resumeInterruptedStream.call(ctx);
|
|
||||||
}
|
|
||||||
ctx.setElapsed(2000); // the last resume played ~16 more minutes
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls through to advancing when no fresh URL can be fetched", async () => {
|
|
||||||
const ctx = makeCtx({ url: null });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.player.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not clobber a different track the user started while the URL was resolving", async () => {
|
|
||||||
const ctx = makeCtx();
|
|
||||||
ctx.provider.getSongUrl.mockImplementation(async () => {
|
|
||||||
ctx.queue.current.mockReturnValue({ id: "other" }); // user ran !next meanwhile
|
|
||||||
return { url: "https://fresh.test/a.m4s" };
|
|
||||||
});
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true); // handled: don't advance again
|
|
||||||
expect(ctx.player.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-75
@@ -193,8 +193,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
private lastSearchResults: Song[] = [];
|
private lastSearchResults: Song[] = [];
|
||||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||||
private effectiveDuration: number | undefined;
|
private effectiveDuration: number | undefined;
|
||||||
/** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */
|
|
||||||
private streamRecovery: { song: QueuedSong; attempts: number; position: number } | null = null;
|
|
||||||
private playGate: Promise<unknown> = Promise.resolve();
|
private playGate: Promise<unknown> = Promise.resolve();
|
||||||
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||||
* null when the wired provider has no reporting capability. */
|
* null when the wired provider has no reporting capability. */
|
||||||
@@ -324,17 +322,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.player.on("trackEnd", () => {
|
this.player.on("trackEnd", () => {
|
||||||
this.resumeInterruptedStream()
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.warn({ err }, "Stream resume failed");
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.then((resumed) => {
|
|
||||||
if (resumed) return;
|
|
||||||
this.logger.debug("Track ended, advancing queue");
|
this.logger.debug("Track ended, advancing queue");
|
||||||
return this.playNext();
|
this.playNext().catch((err) => {
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({ err }, "playNext failed after trackEnd");
|
this.logger.error({ err }, "playNext failed after trackEnd");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1127,69 +1116,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platforms whose CDN stream can die mid-file on long content (#89, #161). */
|
|
||||||
private static readonly RESUMABLE_PLATFORMS: ReadonlySet<Platform> = new Set(["bilibili"]);
|
|
||||||
/** A track that ends within this many seconds of its duration ended normally. */
|
|
||||||
private static readonly STREAM_END_TOLERANCE_S = 30;
|
|
||||||
private static readonly MAX_STREAM_RESUMES = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the player reports a track end. If a B站 stream ended long
|
|
||||||
* before its known duration, the CDN dropped it (#161): fetch a fresh URL
|
|
||||||
* and continue from where it stopped instead of skipping the rest of a
|
|
||||||
* 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no
|
|
||||||
* real progress, so a truly broken stream still advances the queue.
|
|
||||||
*
|
|
||||||
* Returns true when it handled the end (resumed, or a newer track has
|
|
||||||
* already taken over), false when the caller should advance the queue.
|
|
||||||
*/
|
|
||||||
private async resumeInterruptedStream(): Promise<boolean> {
|
|
||||||
const song = this.queue.current();
|
|
||||||
if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const duration = this.effectiveDuration ?? song.duration;
|
|
||||||
const position = Math.floor(this.player.getElapsed());
|
|
||||||
if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recovery = this.streamRecovery;
|
|
||||||
if (
|
|
||||||
!recovery ||
|
|
||||||
recovery.song !== song ||
|
|
||||||
position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S
|
|
||||||
) {
|
|
||||||
this.streamRecovery = { song, attempts: 0, position };
|
|
||||||
}
|
|
||||||
const state = this.streamRecovery!;
|
|
||||||
if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ songId: song.id, position, duration, attempts: state.attempts },
|
|
||||||
"Stream keeps ending early — giving up and advancing",
|
|
||||||
);
|
|
||||||
this.streamRecovery = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
state.attempts++;
|
|
||||||
state.position = position;
|
|
||||||
|
|
||||||
this.logger.warn(
|
|
||||||
{ songId: song.id, position, duration, attempt: state.attempts },
|
|
||||||
"Stream ended before the track did — resuming with a fresh URL",
|
|
||||||
);
|
|
||||||
const result = await this.getProviderFor(song.platform).getSongUrl(song.id);
|
|
||||||
// The user may have skipped/stopped while we were resolving; never
|
|
||||||
// clobber whatever is playing now.
|
|
||||||
if (this.queue.current() !== song || this.player.getState() !== "idle") return true;
|
|
||||||
if (!result?.url || !this.connected) return false;
|
|
||||||
|
|
||||||
song.url = result.url;
|
|
||||||
this.player.play(result.url, position, duration);
|
|
||||||
this.emit("stateChange");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.profileManager.onSongChange(song);
|
await this.profileManager.onSongChange(song);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
|
import { BiliBiliProvider } from "./bilibili.js";
|
||||||
|
|
||||||
describe("BiliBiliProvider.search pagination", () => {
|
describe("BiliBiliProvider.search pagination", () => {
|
||||||
function mockProvider() {
|
function mockProvider() {
|
||||||
@@ -162,44 +162,3 @@ describe("BiliBiliProvider multi-P support", () => {
|
|||||||
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pickStableAudioUrl (#161 long streams dying mid-play)", () => {
|
|
||||||
const pcdn = "https://xy1x2x3x4xy.mcdn.bilivideo.cn:4483/upgcxcode/1/2/3/3-1-30280.m4s?e=x&deadline=1";
|
|
||||||
const szbdyd = "https://cn-hk-eq-01-01.szbdyd.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
const upos = "https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
const upos2 = "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
|
|
||||||
it("prefers an upos/cos mirror over a PCDN baseUrl", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd, upos] })).toBe(upos);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the baseUrl when it is already a stable host", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: upos, backupUrl: [upos2] })).toBe(upos);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the snake_case field names", () => {
|
|
||||||
expect(pickStableAudioUrl({ base_url: pcdn, backup_url: [upos2] })).toBe(upos2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the baseUrl when every candidate is PCDN", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd] })).toBe(pcdn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when there is no url at all", () => {
|
|
||||||
expect(pickStableAudioUrl({})).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getSongUrl returns the stable mirror of the best stream", async () => {
|
|
||||||
const p = new BiliBiliProvider();
|
|
||||||
(p as any).cidCache.set("BV1abc", 42);
|
|
||||||
(p as any).api = {
|
|
||||||
get: vi.fn().mockResolvedValue({
|
|
||||||
data: { data: { dash: { audio: [
|
|
||||||
{ bandwidth: 64000, baseUrl: "https://upos-sz-mirrorcos.bilivideo.com/low.m4s" },
|
|
||||||
{ bandwidth: 320000, baseUrl: pcdn, backupUrl: [upos] },
|
|
||||||
] } } },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
expect((await p.getSongUrl("BV1abc"))?.url).toBe(upos);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-32
@@ -42,37 +42,6 @@ export interface BiliVideoPartsResult {
|
|||||||
parts: BiliVideoPart[];
|
parts: BiliVideoPart[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.szbdyd.com). Their
|
|
||||||
* sessions get cut mid-file, which kills long streams partway (#89, #161),
|
|
||||||
* and a reconnect to the same host rarely recovers.
|
|
||||||
*/
|
|
||||||
const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the audio URL least likely to die mid-stream: the first upos/cos
|
|
||||||
* mirror among baseUrl + backupUrl, else the baseUrl as before.
|
|
||||||
*/
|
|
||||||
export function pickStableAudioUrl(stream: {
|
|
||||||
baseUrl?: string;
|
|
||||||
base_url?: string;
|
|
||||||
backupUrl?: string[];
|
|
||||||
backup_url?: string[];
|
|
||||||
}): string | undefined {
|
|
||||||
const primary = stream.baseUrl ?? stream.base_url;
|
|
||||||
const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter(
|
|
||||||
(u): u is string => typeof u === "string" && u.length > 0,
|
|
||||||
);
|
|
||||||
const stable = candidates.find((u) => {
|
|
||||||
try {
|
|
||||||
return !BILI_PCDN_HOST.test(new URL(u).hostname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return stable ?? primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析带有分P信息的 B站 ID 或 URL。
|
* 解析带有分P信息的 B站 ID 或 URL。
|
||||||
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
||||||
@@ -384,7 +353,7 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
||||||
);
|
);
|
||||||
|
|
||||||
const biliUrl = pickStableAudioUrl(best);
|
const biliUrl = best.baseUrl ?? best.base_url;
|
||||||
return biliUrl ? { url: biliUrl } : null;
|
return biliUrl ? { url: biliUrl } : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in new issue
Block a user