Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:52:47 +08:00
TIANYAO ZHANG 8ff51ea6e0 Merge pull request #166 from xxmod/main
fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
2026-09-27 21:12:25 +08:00
xxmod 3c0e8df763 fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
在网页端播放多P视频时弹出界面选择需要播放的P数,ts里!play播放则只播放第一P
2026-09-22 17:10:54 +08:00
TIANYAO ZHANG 2ea02f54d9 Merge pull request #155 from ZHANGTIANYAO1/fix/152-setup-console-eio
fix(setup): stop a failed console write from aborting setup, require Node 22+ (#152)
2026-08-31 16:22:36 +08:00
saopig1andClaude Opus 5 a804b2edc1 feat(setup)!: require Node 22.12+ and drop Node 20 (#152)
better-sqlite3 stopped publishing prebuilt binaries for Node 20's ABI
(115) in 12.10.0 - upstream, not a mirror gap:

    12.8.0 / 12.9.0   115 127 131 137 141
    12.10.0+              127 137 141 147

`better-sqlite3: ^12.8.0` resolves well past that, so every Node 20
install 404'd on the CDN, fell through to the source build, and demanded
Python plus a C++ toolchain before the bot could start at all. package.json
went on claiming `^20.19.0` worked, and the README went on recommending
Node 20 as one of two blessed versions. It was not a supported
configuration in any meaningful sense - it was a trap.

So say so up front: engines, both setup scripts, and the Docker images now
require Node 22.12+ (or 24+, which still needs a source build for opus).
The version gate in setup.bat / setup.sh is kept byte-identical to the
engines range, as before.

Also copy scripts/lib/console-log.mjs into the production image. The
previous commit had check-native.mjs import it, and the Dockerfile copies
check-native.mjs in on its own for `docker exec ... npm start` - without
its dependency that preflight now dies with ERR_MODULE_NOT_FOUND.

BREAKING CHANGE: Node 20 is no longer supported. Node 22.12 LTS or newer
is required; setup refuses to run on anything older.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 18:27:22 +08:00
saopig1andClaude Opus 5 5e9ae49f52 fix(setup): stop a failed console write from aborting setup (#152)
setup.bat runs `chcp 65001` and shows binary progress on stderr. On some
Windows consoles - the reporter's Windows Server 2012 R2 above all - that
code page cannot render non-ASCII text and the OS fails the write with
EIO. process.stderr is an ordinary stream, so the EIO arrived as an
'error' event, and with no listener attached Node rethrew it as an
uncaught exception:

    Error: write EIO { errno: -4070, code: 'EIO', syscall: 'write' }
        at log (scripts/download-binaries.mjs:84:18)
        at ensureFfmpeg (scripts/download-binaries.mjs:451:5)

Those two frames pin it exactly: line 84 is `process.stderr.write`, and
line 451 is the first log line of the whole run that contains Chinese.
The three lines before it are pure ASCII and printed fine. Nothing was
wrong with the download it was announcing - setup killed itself inside
its own progress logging and reported the native modules as unusable.

scripts/lib/console-log.mjs now wraps both streams: it listens for
'error' so the failure can never be fatal, then degrades that stream
rather than dying - first to an ASCII rendering that keeps the English
half of each bilingual line, then silent if the stream is really gone.
The streams degrade independently, so a console that gives up costs
setup.log nothing: that stdout is a redirected file. check-native.mjs
gets the same treatment, since the console that cannot print its Chinese
is exactly the one a user needs its English from.

Also report a 404 honestly. better-sqlite3 dropped its Node 20 (ABI 115)
prebuilds in 12.10.0 and @discordjs/opus 0.10.0 has none for Node 24, so
users on those majors fall through to the source build and are told to
install Python and a C++ toolchain - when switching Node major takes two
minutes. Nothing in the output said so, and the README recommended
Node 20 as if it still worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:59:31 +08:00
TIANYAO ZHANG 1407cadf7b Merge pull request #154 from XuVIIJay/fix/output-side-seek
fix(audio): 网易云拖动进度条播放中断,改用输出侧 seek 兼容不支持 Range 的 CDN
2026-08-24 11:19:37 +08:00
XuVIIJayandClaude Opus 4.7 9fd1b39092 fix(audio): seek after -i (output-side) so drag-seek works on non-seekable HTTP CDNs
Input-side fast seek (-ss before -i) requires the HTTP server to support
Range/keyframe seeking. NetEase's CDN (music.126.net signed streams) doesn't,
so dragging the progress bar hung FFmpeg and the player force-killed it
(SIGKILL) with no audio. Moving -ss after -i decodes from the start and
discards to the target, which works on any HTTP stream; FFmpeg still fast-seeks
when the CDN supports it, so QQ keeps its instant resume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-20 21:07:21 +08:00
20 changed files with 1409 additions and 54 deletions

No files matched your search

+5 -5
View File
@@ -65,22 +65,22 @@
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。 先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
``` ```
1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/) 1. 安装 Node.js 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
2. 下载或 clone 本项目 2. 下载或 clone 本项目
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身) 3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
4. 双击 scripts\start.bat (启动机器人) 4. 双击 scripts\start.bat (启动机器人)
5. 浏览器打开 http://localhost:3000 5. 浏览器打开 http://localhost:3000
``` ```
> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。 > **先装 Node.js 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
> >
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。 > 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
> >
> 更新的 Node 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。 > **Node 20 已不再支持**:better-sqlite3 从 12.10.0 起不再发布它那个 ABI(115)的预编译包,装起来必须先备好 Python + C++ 构建工具([#152](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/152))。Node 24 及更新的大版本能用,但 @discordjs/opus 0.10.0 同样没有 Node 24(ABI 137)的预编译包,安装脚本会改用源码编译,需要构建工具且耗时更久——所以推荐 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
### 方式二:手动安装(所有系统) ### 方式二:手动安装(所有系统)
**前置条件:** [Node.js 20 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。 **前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
FFmpeg **已自动内置**,无需手动安装。 FFmpeg **已自动内置**,无需手动安装。
```bash ```bash
@@ -510,7 +510,7 @@ teamspeak-music-bot/
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 | | **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) | | **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) | | **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support", "description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.12.0 || >=24.0.0"
}, },
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
+6 -1
View File
@@ -23,6 +23,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createLineWriter } from "./lib/console-log.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules"); const NODE_MODULES = join(ROOT, "node_modules");
@@ -106,7 +107,11 @@ for (const spec of REQUIRED) {
function report() { function report() {
const stamp = readStamp(); const stamp = readStamp();
const mismatch = broken.find((b) => b.abiMismatch); const mismatch = broken.find((b) => b.abiMismatch);
const out = (line) => process.stderr.write(`${line}\n`); // Never let a failed console write become an uncaught error and replace this
// report with a stack trace - the console that cannot print the Chinese half
// of these lines is exactly the one a user needs the English half from.
// See scripts/lib/console-log.mjs and issue #152.
const out = createLineWriter(process.stderr);
out(""); out("");
out("============================================================"); out("============================================================");
+4 -2
View File
@@ -4,7 +4,7 @@
# ========================================== # ==========================================
# --- Stage 1: Build backend + frontend --- # --- Stage 1: Build backend + frontend ---
FROM node:20-slim AS builder FROM node:22-slim AS builder
# Install build tools for native modules (opus, better-sqlite3) # Install build tools for native modules (opus, better-sqlite3)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -30,7 +30,7 @@ RUN npm run build
RUN rm -rf node_modules && npm ci --production && npm cache clean --force RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# --- Stage 2: Production image --- # --- Stage 2: Production image ---
FROM node:20-slim FROM node:22-slim
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled # Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs). # binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
@@ -51,6 +51,8 @@ COPY --from=builder /app/node_modules ./node_modules
# goes through npm, but an interactive `docker exec ... npm start` would # goes through npm, but an interactive `docker exec ... npm start` would
# otherwise die on a missing script rather than starting the bot. # otherwise die on a missing script rather than starting the bot.
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
# ...and the module it imports for crash-proof logging.
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
# Data directory for database, cookies, logs # Data directory for database, cookies, logs
RUN mkdir -p /app/data RUN mkdir -p /app/data
+39 -6
View File
@@ -51,6 +51,7 @@ import { Readable } from "node:stream";
import { execFileSync, execSync } from "node:child_process"; import { execFileSync, execSync } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createLineWriter } from "./lib/console-log.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules"); const NODE_MODULES = join(ROOT, "node_modules");
@@ -61,6 +62,11 @@ const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform; const PLATFORM = process.platform;
const ARCH = process.arch; const ARCH = process.arch;
const NODE_ABI = process.versions.modules; const NODE_ABI = process.versions.modules;
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
/** The newest Node major this project is regularly tested against, and the one
* every required addon currently ships a prebuild for. Keep in sync with
* TESTED_NODE_MAJOR in scripts/setup.bat. */
const TESTED_NODE_MAJOR = 22;
/** Modules the bot cannot start without. ffmpeg-static is optional: a system /** Modules the bot cannot start without. ffmpeg-static is optional: a system
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */ * ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
@@ -79,10 +85,17 @@ const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
// log keeps the full transcript too. // log keeps the full transcript too.
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1"; const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
// Both writers swallow a failed write instead of letting it become an uncaught
// 'error' event: a console that cannot print the Chinese half of a line (issue
// #152) must not be able to abort a whole setup run. The two streams degrade
// independently, so setup.log keeps the full bilingual transcript either way.
const writeErr = createLineWriter(process.stderr);
const writeOut = createLineWriter(process.stdout);
function log(msg) { function log(msg) {
const line = msg === "" ? "" : ` [binary] ${msg}`; const line = msg === "" ? "" : ` [binary] ${msg}`;
process.stderr.write(`${line}\n`); writeErr(line);
if (ECHO_STDOUT) process.stdout.write(`${line}\n`); if (ECHO_STDOUT) writeOut(line);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -401,6 +414,25 @@ function buildFromSource(command) {
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] }); execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
} }
/**
* A 404 from the CDN is not a mirror outage: it means this exact package
* version publishes no prebuilt binary for the running Node ABI at all.
* @discordjs/opus 0.10.0 has no build for Node 24 (ABI 137), so a user on that
* major lands in the source-build fallback below and is told to install Python
* and a C++ toolchain. Switching Node major is the far cheaper fix, and nothing
* else in this output points at it. (better-sqlite3 dropping its Node 20 / ABI
* 115 builds in 12.10.0 is why Node 20 is no longer accepted at all.)
* See issue #152.
*/
function explainMissingPrebuild(name, version, err) {
if (!/HTTP 404/.test(err.message)) return;
log(`${name}: ${name}@${version} ships no prebuilt binary for Node ${NODE_MAJOR} (ABI ${NODE_ABI})`);
log(
`${name}: Node ${TESTED_NODE_MAJOR} LTS has one — switching Node is usually much quicker than ` +
`setting up a compiler (换用 Node ${TESTED_NODE_MAJOR} LTS 通常比装编译环境快得多)`,
);
}
function buildToolsHint() { function buildToolsHint() {
log("Install build tools first:"); log("Install build tools first:");
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)"); log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
@@ -521,6 +553,7 @@ async function ensureOpus() {
log(`${name}: prebuilt binary installed`); log(`${name}: prebuilt binary installed`);
} catch (cdnErr) { } catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`); log(`${name}: CDN install failed (${cdnErr.message})`);
explainMissingPrebuild(name, version, cdnErr);
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`); log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name}`); buildFromSource(`npm rebuild ${name}`);
} }
@@ -591,6 +624,7 @@ async function ensureBetterSqlite3() {
log(`${name}: prebuilt binary installed (${humanSize(dest)})`); log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
} catch (cdnErr) { } catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`); log(`${name}: CDN install failed (${cdnErr.message})`);
explainMissingPrebuild(name, version, cdnErr);
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`); log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name} --build-from-source`); buildFromSource(`npm rebuild ${name} --build-from-source`);
} }
@@ -661,10 +695,9 @@ try {
// that same loop. Run these concurrently and the first module to fall back to // that same loop. Run these concurrently and the first module to fall back to
// a source build kills every download still in flight — the connection is // a source build kills every download still in flight — the connection is
// healthy, the timer just never got a chance to be reset. That is not a rare // healthy, the timer just never got a chance to be reset. That is not a rare
// race: npmmirror has no opus prebuild for ABI 137 (Node 24) and no // race: @discordjs/opus 0.10.0 has no prebuild for ABI 137, so on Node 24 that
// better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node // module 404s within ~100ms and starts building while ffmpeg's ~80MB download
// versions this project supports, one module 404s within ~100ms and starts // is still going. ffmpeg is optional,
// building while ffmpeg's ~80MB download is still going. ffmpeg is optional,
// so the spurious failure used to be swallowed as a WARN and setup still // so the spurious failure used to be swallowed as a WARN and setup still
// reported success — leaving the user with no ffmpeg and no working playback. // reported success — leaving the user with no ffmpeg and no working playback.
// Nothing here benefits from overlap anyway: every probe is execFileSync. // Nothing here benefits from overlap anyway: every probe is execFileSync.
+142
View File
@@ -0,0 +1,142 @@
/**
* Crash-proof line logging for the setup scripts.
*
* WHY THIS EXISTS (issue #152)
* ----------------------------
* setup.bat runs `chcp 65001` and shows progress on stderr. Some Windows
* consoles - Windows Server 2012 R2 above all - cannot render non-ASCII text in
* that code page and the OS fails the write with EIO. `process.stderr` is an
* ordinary stream, so that EIO arrives as an 'error' event, and a stream with
* no 'error' listener rethrows it as an uncaught exception:
*
* Error: write EIO
* at afterWriteDispatched (node:internal/stream_base_commons:159:15)
* ...
* at log (scripts/download-binaries.mjs:84:18)
* at ensureFfmpeg (scripts/download-binaries.mjs:451:5)
*
* That is setup killing itself inside its own progress logging, on the first
* line of the run that happened to contain Chinese - nothing was wrong with the
* download it was about to start.
*
* So: listen for the error and degrade instead of dying.
* full -> ascii : drop the CJK the console choked on, keep the English half
* ascii -> off : the stream is simply gone (closed pipe) - stay quiet
* Each stream degrades on its own, so a console that gives up does not cost
* setup.log its full bilingual transcript: that stdout is a redirected file.
*/
const HAS_NON_ASCII = /[^\x00-\x7F]/;
/** Placeholders for a removed run: one that separated words, one that did not. */
const SPACED = "\u0000";
const TIGHT = "\u0001";
/** Punctuation the bilingual strings use that has an obvious ASCII twin. */
const PUNCTUATION = new Map(
Object.entries({
"—": "-",
"–": "-",
"…": "...",
"“": '"',
"”": '"',
"‘": "'",
"’": "'",
",": ",",
"。": ".",
"、": ",",
":": ":",
";": ";",
"(": "(",
")": ")",
"!": "!",
"?": "?",
"←": "<-",
"→": "->",
"×": "x",
}),
);
/**
* Best-effort ASCII rendering of a log line, for a console that cannot print
* anything else. Returns null when nothing worth printing survives - every
* Chinese-only line in these scripts sits directly beside an English line
* saying the same thing, so dropping it loses no information.
*/
export function toAsciiFallback(text) {
if (!HAS_NON_ASCII.test(text)) return text;
let out = "";
for (const ch of text) out += PUNCTUATION.get(ch) ?? ch;
out = out
.replace(/[\u0000\u0001]/g, "")
// Mark each removed run rather than just deleting it, so the tidy-up below
// can tell "a separator that introduced text we dropped" from "a separator
// that belongs to the English half". SPACED was holding two ASCII words
// apart; TIGHT was hugging a bracket or a comma.
.replace(/[ \t]*[^\x00-\x7F]+[ \t]*/g, (run) =>
/^[ \t]/.test(run) && /[ \t]$/.test(run) ? SPACED : TIGHT,
)
// "(可能需要几分钟)" — the parentheses held nothing else.
.replace(/[ \t]*\([ \t]*(?:[\u0000\u0001][ \t]*)+\)/g, "")
// "FAILED — 编译失败", "(~80 MB, 请耐心等待)" — drop the trailing marks along
// with the separators that were only ever there to introduce them.
.replace(/[ \t]*[-,;:]*[ \t]*(?:[\u0000\u0001][ \t,;:-]*)+(?=[)\]]|$)/gm, "")
.replace(/\u0000/g, " ")
.replace(/\u0001/g, "")
.replace(/[ \t]+$/gm, "");
return /[A-Za-z0-9]/.test(out) ? out : null;
}
/** One degradation state per stream, shared by every writer built on it. */
const guards = new WeakMap();
function guardFor(stream) {
const existing = guards.get(stream);
if (existing) return existing;
const guard = { mode: "full" };
guards.set(stream, guard);
try {
// The whole point: without this listener the next EIO/EPIPE is fatal.
stream.on("error", () => degrade(guard));
} catch {
/* not an EventEmitter - the try/catch around write() still guards us */
}
return guard;
}
function degrade(guard) {
guard.mode = guard.mode === "full" ? "ascii" : "off";
}
/**
* Build a `writeLine(text)` that appends a newline, never throws, and never
* lets a failed console write take the process down with it.
* Returns true when the line reached the stream.
*/
export function createLineWriter(stream) {
const guard = guardFor(stream);
return function writeLine(text) {
if (guard.mode === "off") return false;
let line = text;
if (guard.mode === "ascii") {
line = toAsciiFallback(text);
if (line === null) return false;
}
try {
stream.write(`${line}\n`);
return true;
} catch {
// A synchronous throw (EBADF on a closed handle) never reaches the
// 'error' listener, so degrade here too.
degrade(guard);
return false;
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { createLineWriter, toAsciiFallback } from "./console-log.mjs";
/** Stand-in for process.stderr: an EventEmitter with a write() we can steer. */
function fakeStream() {
const stream = new EventEmitter();
stream.written = [];
stream.throwOnWrite = false;
stream.write = (chunk) => {
if (stream.throwOnWrite) throw new Error("EBADF");
stream.written.push(chunk);
return true;
};
return stream;
}
describe("toAsciiFallback", () => {
it("leaves ASCII lines exactly as they are", () => {
const line = " [binary] better-sqlite3: OK (loads under v22.23.2, ABI 127)";
expect(toAsciiFallback(line)).toBe(line);
expect(toAsciiFallback("")).toBe("");
});
it("keeps the English half of the line that crashed setup in #152", () => {
expect(
toAsciiFallback(
" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)",
),
).toBe(" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB)");
});
it("drops parentheses and separators left stranded by the removed text", () => {
expect(
toAsciiFallback(" [binary] better-sqlite3: falling back — 'npm rebuild' (可能需要几分钟)"),
).toBe(" [binary] better-sqlite3: falling back - 'npm rebuild'");
expect(
toAsciiFallback(" Windows: npm install --global windows-build-tools (或安装 VS Build Tools)"),
).toBe(" Windows: npm install --global windows-build-tools (VS Build Tools)");
});
it("drops a Chinese-only line, which always has an English twin beside it", () => {
expect(toAsciiFallback("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。")).toBeNull();
});
it("preserves the indentation the summary is aligned on, and drops the dangling dash", () => {
expect(toAsciiFallback(" - better-sqlite3 FAILED — 编译失败")).toBe(
" - better-sqlite3 FAILED",
);
});
it("keeps a separator that belongs to the English half", () => {
expect(toAsciiFallback("Summary — Node v22.0.0 / ABI 127 / win32-x64:")).toBe(
"Summary - Node v22.0.0 / ABI 127 / win32-x64:",
);
});
});
describe("createLineWriter", () => {
it("appends a newline and reports the write", () => {
const stream = fakeStream();
expect(createLineWriter(stream)("hello")).toBe(true);
expect(stream.written).toEqual(["hello\n"]);
});
it("survives the EIO that killed setup: an 'error' event must not throw", () => {
const stream = fakeStream();
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
expect(() => stream.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" }))).not.toThrow();
});
it("falls back to ASCII once the console has refused a line", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
write(" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)");
stream.emit("error", new Error("write EIO"));
write(" [binary] GET https://cdn/opus.tar.gz (~1 MB, 这一步比较慢,请耐心等待)");
expect(stream.written).toEqual([
" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)\n",
" [binary] GET https://cdn/opus.tar.gz (~1 MB)\n",
]);
});
it("goes quiet after a second failure rather than retrying a dead stream", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.emit("error", new Error("write EIO"));
stream.emit("error", new Error("write EPIPE"));
expect(write("anything at all")).toBe(false);
expect(stream.written).toEqual([]);
});
it("degrades on a synchronous throw too, which never reaches the listener", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.throwOnWrite = true;
expect(write(" [binary] 下载中 downloading")).toBe(false);
stream.throwOnWrite = false;
write(" [binary] 下载中 downloading");
expect(stream.written).toEqual([" [binary] downloading\n"]);
});
it("degrades each stream on its own, so setup.log keeps the full transcript", () => {
const console_ = fakeStream();
const logFile = fakeStream();
const writeConsole = createLineWriter(console_);
const writeLog = createLineWriter(logFile);
console_.emit("error", new Error("write EIO"));
const line = " [binary] ffmpeg-static: 下载完成 done";
writeConsole(line);
writeLog(line);
expect(console_.written).toEqual([" [binary] ffmpeg-static: done\n"]);
expect(logFile.written).toEqual([`${line}\n`]);
});
it("never installs a second listener for a stream that already has a writer", () => {
const stream = fakeStream();
createLineWriter(stream);
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
});
it("still guards a stream that is not an EventEmitter", () => {
const stream = { write: vi.fn(() => { throw new Error("EBADF"); }) };
const write = createLineWriter(stream);
expect(() => write("line")).not.toThrow();
expect(write("line")).toBe(false);
});
});
+11 -9
View File
@@ -11,7 +11,7 @@ title TSMusicBot Setup
:: ============================================================ :: ============================================================
set "SCRIPT_VERSION=2.2" set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=20" set "MIN_NODE_MAJOR=22"
:: Newest Node major this project is regularly tested against. Anything above :: Newest Node major this project is regularly tested against. Anything above
:: still works, it just may have no prebuilt addons and fall back to a source build. :: still works, it just may have no prebuilt addons and fall back to a source build.
set "TESTED_NODE_MAJOR=22" set "TESTED_NODE_MAJOR=22"
@@ -67,13 +67,15 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%" call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER% echo [OK] Node.js found: %NODE_VER%
:: The supported floor is not just a major version, so let node decide: :: The supported floor is not just a major version, so let node decide.
:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs :: Node 20 was dropped: better-sqlite3 ships no prebuilt binary for its ABI
:: >=20.17 / >=22.9, and the odd majors (21 / 23) are excluded by :: (115) since 12.10.0, so every Node 20 install needed Python and a C++
:: better-sqlite3 and vitest. Keep this in sync with "engines" in package.json. :: toolchain just to get off the ground (issue #152). The odd majors (21 /
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)" :: 23) are excluded by better-sqlite3 and vitest.
:: Keep this in sync with "engines" in package.json.
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
if errorlevel 1 ( if errorlevel 1 (
call :error "Node.js %NODE_VER% is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS." call :error "Node.js %NODE_VER% is not supported. Use Node 22.12+ LTS or newer."
echo Download: https://nodejs.org/ or https://nodejs.cn/ echo Download: https://nodejs.org/ or https://nodejs.cn/
pause pause
exit /b 1 exit /b 1
@@ -86,10 +88,10 @@ if errorlevel 1 (
:: characters and starts eating the "echo " prefix of following lines. :: characters and starts eating the "echo " prefix of following lines.
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably. :: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% ( if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22. echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 22.
echo Newer Node majors may have no prebuilt opus / better-sqlite3, echo Newer Node majors may have no prebuilt opus / better-sqlite3,
echo so setup falls back to a source build - slower, needs C++ build tools. echo so setup falls back to a source build - slower, needs C++ build tools.
echo Recommended: Node 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/ echo Recommended: Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
echo This is only a warning; setup still builds the binaries for %NODE_VER%. echo This is only a warning; setup still builds the binaries for %NODE_VER%.
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%" call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
) )
+8 -6
View File
@@ -32,16 +32,18 @@ echo "[OK] Node.js $(node -v)"
TESTED_NODE_MAJOR=22 TESTED_NODE_MAJOR=22
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client # The floor is not just a major version, so let node decide. Node 20 was dropped:
# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors # better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines". # Node 20 install needed Python and a C++ toolchain just to get off the ground
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then # (issue #152). The odd majors (21 / 23) are excluded by better-sqlite3 and vitest.
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS." # Keep in sync with package.json "engines".
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
echo " https://nodejs.org/ | https://nodejs.cn/" echo " https://nodejs.org/ | https://nodejs.cn/"
exit 1 exit 1
fi fi
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 20 / Node 22)." echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 22)."
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包," echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。" echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
echo " This is only a warning - setup builds the binaries for $(node -v) either way." echo " This is only a warning - setup builds the binaries for $(node -v) either way."
+10 -2
View File
@@ -55,13 +55,21 @@ 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("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", () => {
+12 -3
View File
@@ -76,8 +76,9 @@ 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 (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) { if (isBilibili) {
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`,
@@ -103,8 +104,16 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx", "-reconnect_on_http_error", "4xx,5xx",
); );
} }
if (seekSeconds > 0) args.push("-ss", String(seekSeconds)); // B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"); // to the byte offset. Output-side seek would download and decode everything
// before the target first — minutes for a resume deep into a 3-hour video
// (#161), long enough to trip the stall watchdog.
const inputSideSeek = isBilibili;
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args; return args;
} }
+146
View File
@@ -1538,3 +1538,149 @@ 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("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();
});
});
+95 -3
View File
@@ -193,6 +193,8 @@ 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. */
@@ -322,8 +324,17 @@ 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");
this.playNext().catch((err) => { return this.playNext();
})
.catch((err) => {
this.logger.error({ err }, "playNext failed after trackEnd"); this.logger.error({ err }, "playNext failed after trackEnd");
}); });
}); });
@@ -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");
@@ -1108,6 +1127,69 @@ export class BotInstance extends EventEmitter {
} }
} }
/** Platforms whose CDN stream can die mid-file on long content (#89, #161). */
private static readonly RESUMABLE_PLATFORMS: ReadonlySet<Platform> = new Set(["bilibili"]);
/** A track that ends within this many seconds of its duration ended normally. */
private static readonly STREAM_END_TOLERANCE_S = 30;
private static readonly MAX_STREAM_RESUMES = 3;
/**
* Called when the player reports a track end. If a B站 stream ended long
* before its known duration, the CDN dropped it (#161): fetch a fresh URL
* and continue from where it stopped instead of skipping the rest of a
* 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no
* real progress, so a truly broken stream still advances the queue.
*
* Returns true when it handled the end (resumed, or a newer track has
* already taken over), false when the caller should advance the queue.
*/
private async resumeInterruptedStream(): Promise<boolean> {
const song = this.queue.current();
if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) {
return false;
}
const duration = this.effectiveDuration ?? song.duration;
const position = Math.floor(this.player.getElapsed());
if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) {
return false;
}
const recovery = this.streamRecovery;
if (
!recovery ||
recovery.song !== song ||
position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S
) {
this.streamRecovery = { song, attempts: 0, position };
}
const state = this.streamRecovery!;
if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) {
this.logger.warn(
{ songId: song.id, position, duration, attempts: state.attempts },
"Stream keeps ending early — giving up and advancing",
);
this.streamRecovery = null;
return false;
}
state.attempts++;
state.position = position;
this.logger.warn(
{ songId: song.id, position, duration, attempt: state.attempts },
"Stream ended before the track did — resuming with a fresh URL",
);
const result = await this.getProviderFor(song.platform).getSongUrl(song.id);
// The user may have skipped/stopped while we were resolving; never
// clobber whatever is playing now.
if (this.queue.current() !== song || this.player.getState() !== "idle") return true;
if (!result?.url || !this.connected) return false;
song.url = result.url;
this.player.play(result.url, position, duration);
this.emit("stateChange");
return true;
}
private async syncProfileToSong(song: QueuedSong | null): Promise<void> { private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try { try {
await this.profileManager.onSongChange(song); await this.profileManager.onSongChange(song);
@@ -1134,7 +1216,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 +1238,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> {
+167 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js"; import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => { describe("BiliBiliProvider.search pagination", () => {
function mockProvider() { function mockProvider() {
@@ -37,3 +37,169 @@ 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
});
});
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);
});
});
+138 -12
View File
@@ -27,6 +27,69 @@ 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[];
}
/**
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.szbdyd.com). Their
* sessions get cut mid-file, which kills long streams partway (#89, #161),
* and a reconnect to the same host rarely recovers.
*/
const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i;
/**
* Pick the audio URL least likely to die mid-stream: the first upos/cos
* mirror among baseUrl + backupUrl, else the baseUrl as before.
*/
export function pickStableAudioUrl(stream: {
baseUrl?: string;
base_url?: string;
backupUrl?: string[];
backup_url?: string[];
}): string | undefined {
const primary = stream.baseUrl ?? stream.base_url;
const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter(
(u): u is string => typeof u === "string" && u.length > 0,
);
const stable = candidates.find((u) => {
try {
return !BILI_PCDN_HOST.test(new URL(u).hostname);
} catch {
return false;
}
});
return stable ?? primary;
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
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 +255,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 +297,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 +306,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 +355,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,
@@ -258,7 +384,7 @@ export class BiliBiliProvider implements MusicProvider {
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
); );
const biliUrl = best.baseUrl ?? best.base_url; const biliUrl = pickStableAudioUrl(best);
return biliUrl ? { url: biliUrl } : null; return biliUrl ? { url: biliUrl } : null;
} catch { } catch {
return null; return null;
+65
View File
@@ -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);
});
});
+25
View File
@@ -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.
+2
View File
@@ -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();
+293
View File
@@ -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>
+107 -3
View File
@@ -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 });
}, },