diff --git a/README.md b/README.md index 65c4b0a..f4d9a3b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
-
+
@@ -62,20 +62,25 @@
### 方式一:Windows 一键部署(最简单)
-只需电脑有网络连接,其他一切自动安装。
+先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
```
-1. 下载或 clone 本项目
-2. 双击 scripts\setup.bat (首次安装,自动安装 Node.js 和所有依赖)
-3. 双击 scripts\start.bat (启动机器人)
-4. 浏览器打开 http://localhost:3000
+1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
+2. 下载或 clone 本项目
+3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
+4. 双击 scripts\start.bat (启动机器人)
+5. 浏览器打开 http://localhost:3000
```
-> `setup.bat` 会自动通过 winget 安装 Node.js(如果未安装),运行 `npm install` 安装所有依赖(包括内置 FFmpeg),最后构建项目。之后每次只需双击 `start.bat` 启动。
+> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
+>
+> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
+>
+> 更新的 Node 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
### 方式二:手动安装(所有系统)
-**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
+**前置条件:** [Node.js 20 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
FFmpeg **已自动内置**,无需手动安装。
```bash
@@ -505,7 +510,7 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
-| **运行时** | Node.js 20+, TypeScript 5 |
+| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
@@ -797,6 +802,9 @@ A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
+**Q:启动报 `NODE_MODULE_VERSION 137 ... requires 127`,或提示找不到 `opus.node`?**
+A:换过 Node 大版本了。原生模块(`@discordjs/opus`、`better-sqlite3`)编译时绑定了一个 Node ABI(Node 20 = 115、22 = 127、24 = 137),换版本后旧的 `.node` 就再也加载不了。**重新运行一次 `scripts\setup.bat`(Linux/macOS 是 `bash scripts/setup.sh`)即可**——安装脚本会实际加载一遍每个原生模块,发现和当前 Node 不匹配就自动重新下载/编译,替换过程中失败也会把原来的文件还原回去。`start.bat` 和 `npm start` 在启动前也会先做这个检查,直接告诉你哪个模块对不上、分别是哪个 ABI,而不是抛一串看不懂的堆栈。想彻底重来就删掉 `node_modules` 和 `web\node_modules` 再跑一次 `setup.bat`。
+
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
diff --git a/package.json b/package.json
index 231a5f2..1cdd85a 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,15 @@
"version": "0.1.0",
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && npm run build:web",
"build:web": "cd web && npm run build",
+ "check:native": "node scripts/check-native.mjs",
+ "prestart": "node scripts/check-native.mjs",
"start": "node dist/index.js",
"play": "node dist/index.js",
"test": "vitest run",
diff --git a/scripts/check-native.mjs b/scripts/check-native.mjs
new file mode 100644
index 0000000..4393628
--- /dev/null
+++ b/scripts/check-native.mjs
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+
+/**
+ * Preflight: can THIS Node build actually load the native modules that are
+ * sitting in node_modules?
+ *
+ * A compiled addon is tied to one Node ABI (process.versions.modules:
+ * Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major,
+ * launch under another, and the bot dies deep inside startup with a
+ * `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it.
+ * This script turns that into one actionable sentence, before anything starts.
+ *
+ * Exit code:
+ * 0 every required native module loads (or is simply not installed yet —
+ * that is npm install's problem, not an ABI problem)
+ * 1 a required native module definitively fails to load; the bot could not
+ * have started anyway, so there is no false-positive risk here.
+ *
+ * Usage: node scripts/check-native.mjs
+ */
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+const NODE_MODULES = join(ROOT, "node_modules");
+const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
+const NODE_ABI = process.versions.modules;
+
+/** Only the modules the bot cannot start without. ffmpeg-static is optional
+ * (a system ffmpeg on PATH works too), so it is not checked here. */
+const REQUIRED = ["@discordjs/opus", "better-sqlite3"];
+
+function pkgDirOf(spec) {
+ return join(NODE_MODULES, ...spec.split("/"));
+}
+
+function summarizeError(text) {
+ const lines = String(text || "")
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter(Boolean);
+ const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
+ return (interesting || lines[0] || "unknown error").slice(0, 300);
+}
+
+/**
+ * The snippet that actually forces each package's addon to be dlopen()ed.
+ * NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor,
+ * so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI
+ * binary. Opening an in-memory database is the cheapest way to really load it.
+ */
+const PROBE_EXPR = {
+ "@discordjs/opus": "require('@discordjs/opus')",
+ "better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
+};
+
+/**
+ * Load-probe in a throwaway child process. Child process on purpose: requiring
+ * an addon in this process would keep the DLL mapped, and Windows then refuses
+ * to let setup.bat replace the file we just told the user to replace.
+ */
+function probeRequire(spec) {
+ const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
+ try {
+ execFileSync(process.execPath, ["-e", expr], {
+ cwd: ROOT,
+ stdio: "pipe",
+ timeout: 120000,
+ windowsHide: true,
+ });
+ return { ok: true };
+ } catch (err) {
+ const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
+ // "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
+ // requires NODE_MODULE_VERSION 127..." -> first number is the build target.
+ const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
+ return {
+ ok: false,
+ abiMismatch: abis.length >= 2,
+ compiledAbi: abis.length >= 2 ? abis[0] : null,
+ error: summarizeError(text),
+ };
+ }
+}
+
+function readStamp() {
+ try {
+ return JSON.parse(readFileSync(STAMP_FILE, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh";
+
+const broken = [];
+for (const spec of REQUIRED) {
+ if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job
+ const probe = probeRequire(spec);
+ if (!probe.ok) broken.push({ spec, ...probe });
+}
+
+function report() {
+ const stamp = readStamp();
+ const mismatch = broken.find((b) => b.abiMismatch);
+ const out = (line) => process.stderr.write(`${line}\n`);
+
+ out("");
+ out("============================================================");
+ if (mismatch) {
+ out(" [ERROR] 原生模块与当前 Node 版本不匹配");
+ out(" Native modules do not match this Node version");
+ } else {
+ out(" [ERROR] 原生模块无法加载 / native module failed to load");
+ }
+ out("============================================================");
+ out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`);
+ if (stamp && stamp.abi) {
+ out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`);
+ out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`);
+ }
+ out("");
+ for (const b of broken) {
+ if (b.abiMismatch) {
+ out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`);
+ out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`);
+ out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`);
+ } else {
+ out(` x ${b.spec}: ${b.error}`);
+ }
+ }
+ out("");
+ out(" 怎么修 / How to fix:");
+ out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`);
+ out(" (它会自动为当前 Node 版本重新安装原生模块)");
+ out(" (setup now repairs the native modules for whatever Node you run)");
+ out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version");
+ out(" you installed with, then start again.");
+ out("============================================================");
+ out("");
+}
+
+if (broken.length > 0) {
+ report();
+ // exitCode rather than exit(): lets the message flush when stderr is piped.
+ process.exitCode = 1;
+}
diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile
index eee9a67..735bb33 100644
--- a/scripts/docker/Dockerfile
+++ b/scripts/docker/Dockerfile
@@ -46,6 +46,11 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
+# package.json declares a `prestart` preflight, so `npm start` inside the
+# container needs this file. The image's own CMD calls node directly and never
+# goes through npm, but an interactive `docker exec ... npm start` would
+# otherwise die on a missing script rather than starting the bot.
+COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
diff --git a/scripts/download-binaries.mjs b/scripts/download-binaries.mjs
index 18b2623..2403f14 100755
--- a/scripts/download-binaries.mjs
+++ b/scripts/download-binaries.mjs
@@ -1,161 +1,718 @@
#!/usr/bin/env node
/**
- * Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
- * Called by setup.bat after npm install --ignore-scripts.
+ * Verify / download / repair the native binaries used by TSMusicBot
+ * (ffmpeg-static + @discordjs/opus + better-sqlite3), preferring the
+ * npmmirror CDN so China users never have to reach GitHub.
+ *
+ * Called by setup.bat / setup.sh after `npm install --ignore-scripts`.
+ *
+ * WHY THIS IS NOT JUST A DOWNLOADER
+ * ---------------------------------
+ * A compiled addon only loads into the exact Node ABI it was built for
+ * (process.versions.modules: Node 20 = 115, Node 22 = 127, Node 24 = 137).
+ * better-sqlite3 stores its addon at an ABI-agnostic path
+ * (build/Release/better_sqlite3.node), so a "file exists and is big enough"
+ * check happily keeps a binary built for a *different* Node major around and
+ * the bot then dies with `NODE_MODULE_VERSION 137 ... requires 127`.
+ * So we validate by actually LOADING each package — in a short-lived child
+ * process, because on Windows a loaded .node stays mapped and the OS then
+ * refuses to delete or overwrite it.
+ *
+ * Every repair is staged and swapped in atomically: if a download fails we put
+ * the previous file back, so a failed run can never leave the install in a
+ * worse state than it started.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
+ * Env: TSMB_BINARY_LOG_STDOUT=1 also echo progress to stdout
+ * (setup.bat uses this to show progress live on stderr while stdout
+ * is redirected into setup.log)
*/
-import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
+import {
+ chmodSync,
+ createWriteStream,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
-import { join, dirname } from "node:path";
+import { basename, dirname, join } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
-import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
-import { execSync } from "node:child_process";
+import { execFileSync, execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+const NODE_MODULES = join(ROOT, "node_modules");
+const BACKUP_DIR = join(NODE_MODULES, ".tsmusicbot-backup");
+const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
+
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
-function download(url) {
+/** 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. */
+const REQUIRED = new Set(["@discordjs/opus", "better-sqlite3"]);
+
+/** ffmpeg-static ships ~40-90 MB depending on platform; anything under this is
+ * certainly a truncated download, not a real build. */
+const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
+
+// ---------------------------------------------------------------------------
+// logging
+// ---------------------------------------------------------------------------
+
+// Progress goes to stderr so setup.bat can show it live while stdout is being
+// appended to setup.log. TSMB_BINARY_LOG_STDOUT=1 mirrors it into stdout so the
+// log keeps the full transcript too.
+const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
+
+function log(msg) {
+ const line = msg === "" ? "" : ` [binary] ${msg}`;
+ process.stderr.write(`${line}\n`);
+ if (ECHO_STDOUT) process.stdout.write(`${line}\n`);
+}
+
+// ---------------------------------------------------------------------------
+// small helpers
+// ---------------------------------------------------------------------------
+
+function sizeOf(filePath) {
+ try {
+ return statSync(filePath).size;
+ } catch {
+ return 0;
+ }
+}
+
+function humanSize(filePath) {
+ const bytes = sizeOf(filePath);
+ if (!bytes) return "unknown size";
+ return bytes >= 1024 * 1024
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
+ : `${(bytes / 1024).toFixed(0)} KB`;
+}
+
+function ensureExecutable(filePath) {
+ if (PLATFORM === "win32") return;
+ try {
+ chmodSync(filePath, 0o755);
+ } catch {
+ /* best effort */
+ }
+}
+
+/** Read the version actually present in node_modules (never hardcode it: the
+ * lockfile can be far ahead of whatever version this script was written for,
+ * and a wrong version means a 404 on the CDN). */
+function readInstalledVersion(spec) {
+ try {
+ const pkgJson = join(NODE_MODULES, ...spec.split("/"), "package.json");
+ const version = JSON.parse(readFileSync(pkgJson, "utf8")).version;
+ return typeof version === "string" && version ? version : null;
+ } catch {
+ return null;
+ }
+}
+
+function summarizeError(text) {
+ const lines = String(text || "")
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter(Boolean);
+ const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
+ return (interesting || lines[0] || "unknown error").slice(0, 300);
+}
+
+// ---------------------------------------------------------------------------
+// download
+// ---------------------------------------------------------------------------
+
+function download(url, redirects = 0) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
- if (res.statusCode < 200 || res.statusCode >= 400) {
- reject(new Error(`HTTP ${res.statusCode}: ${url}`));
+ const { statusCode, headers } = res;
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
+ res.resume();
+ if (redirects >= 5) {
+ reject(new Error(`too many redirects: ${url}`));
+ return;
+ }
+ resolve(download(new URL(headers.location, url).toString(), redirects + 1));
+ return;
+ }
+ if (statusCode < 200 || statusCode >= 300) {
+ res.resume();
+ reject(new Error(`HTTP ${statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
+ res.on("error", reject);
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
- req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
+ req.on("timeout", () => {
+ req.destroy();
+ reject(new Error(`timeout: ${url}`));
+ });
});
}
-function log(msg) {
- console.log(` [binary] ${msg}`);
+let tarModule = null;
+
+/** `tar` is not a declared dependency — it only resolves transitively through
+ * prebuild-install / @discordjs/node-pre-gyp. Fail with a sentence a user can
+ * act on instead of a raw MODULE_NOT_FOUND stack. */
+function loadTar() {
+ if (tarModule) return tarModule;
+ try {
+ tarModule = createRequire(import.meta.url)("tar");
+ } catch {
+ throw new Error(
+ "'tar' module not available / 找不到 tar 模块 — run `npm install tar` in the project root and retry",
+ );
+ }
+ return tarModule;
}
-function isValidSize(filePath, minBytes) {
- try { return statSync(filePath).size >= minBytes; } catch { return false; }
+async function extractTarGz(buf, cwd) {
+ const tar = loadTar();
+ const tmpFile = join(tmpdir(), `tsmb-${process.pid}-${Date.now()}.tar.gz`);
+ writeFileSync(tmpFile, buf);
+ try {
+ await tar.extract({ cwd, file: tmpFile });
+ } finally {
+ try {
+ rmSync(tmpFile, { force: true });
+ } catch {
+ /* ignore */
+ }
+ }
}
-async function downloadFfmpeg() {
- const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
+// ---------------------------------------------------------------------------
+// load probe (the whole point of this rewrite)
+// ---------------------------------------------------------------------------
+
+/**
+ * The snippet that actually forces each package's addon to be dlopen()ed.
+ * NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor
+ * (lib/database.js: `DEFAULT_ADDON || (DEFAULT_ADDON = require('bindings')(...))`),
+ * so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI binary.
+ * Opening an in-memory database is the cheapest way to really load it.
+ */
+const PROBE_EXPR = {
+ "@discordjs/opus": "require('@discordjs/opus')",
+ "better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
+};
+
+/**
+ * Try to load a package in a throwaway child process.
+ * Child process on purpose: loading an addon here would keep the DLL mapped and
+ * Windows would then refuse to rename/delete the file we are about to replace.
+ */
+function probeRequire(spec) {
+ const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
+ try {
+ execFileSync(process.execPath, ["-e", expr], {
+ cwd: ROOT,
+ stdio: "pipe",
+ timeout: 120000,
+ windowsHide: true,
+ });
+ return { ok: true };
+ } catch (err) {
+ const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
+ // "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
+ // requires NODE_MODULE_VERSION 127..." -> first number is what it was built for.
+ const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
+ return {
+ ok: false,
+ abiMismatch: abis.length >= 2,
+ compiledAbi: abis.length >= 2 ? abis[0] : null,
+ error: summarizeError(text),
+ };
+ }
+}
+
+function describeProbe(probe) {
+ if (probe.abiMismatch) {
+ return `built for Node ABI ${probe.compiledAbi}, but this Node needs ABI ${NODE_ABI}`;
+ }
+ return probe.error;
+}
+
+function probeFfmpegBinary(bin) {
+ try {
+ const out = execFileSync(bin, ["-version"], {
+ stdio: "pipe",
+ timeout: 30000,
+ windowsHide: true,
+ }).toString();
+ return { ok: true, version: (out.split(/\r?\n/)[0] || "").slice(0, 60) };
+ } catch (err) {
+ const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
+ return { ok: false, error: summarizeError(text) };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// atomic swap helpers
+// ---------------------------------------------------------------------------
+
+let stashCounter = 0;
+
+/**
+ * Move `target` (file or directory) out of the way into node_modules/.tsmusicbot-backup.
+ * Same volume as node_modules, so the rename is atomic, and outside the package's
+ * build/ tree so that `npm rebuild` / `node-gyp clean` cannot wipe the backup.
+ * Returns { commit, restore } — call exactly one of them.
+ */
+/** Windows likes to hold a brief lock on a freshly written .node (antivirus,
+ * indexer), and rmSync does not retry by default. */
+const RM_OPTS = { recursive: true, force: true, maxRetries: 5, retryDelay: 150 };
+
+function stash(target) {
+ if (!existsSync(target)) {
+ return { commit() {}, restore() {} };
+ }
+ mkdirSync(BACKUP_DIR, { recursive: true });
+ const backup = join(BACKUP_DIR, `${basename(target)}.${process.pid}.${stashCounter++}.bak`);
+ rmSync(backup, RM_OPTS);
+ renameSync(target, backup);
+ // The backup filename alone cannot say where the artifact came from, and a
+ // run that is killed (Ctrl+C during a slow download) never reaches commit or
+ // restore. Record the target so the next run can put it back — see
+ // recoverOrphanedBackups().
+ const manifest = `${backup}.json`;
+ try {
+ writeFileSync(manifest, `${JSON.stringify({ target })}\n`);
+ } catch {
+ /* recovery is best-effort; the swap itself still works */
+ }
+
+ let settled = false;
+ const dropManifest = () => {
+ try {
+ rmSync(manifest, RM_OPTS);
+ } catch {
+ /* ignore */
+ }
+ };
+ return {
+ commit() {
+ if (settled) return;
+ settled = true;
+ try {
+ rmSync(backup, RM_OPTS);
+ } catch {
+ /* leftover backup is harmless */
+ }
+ dropManifest();
+ },
+ restore() {
+ if (settled) return;
+ settled = true;
+ try {
+ rmSync(target, RM_OPTS);
+ mkdirSync(dirname(target), { recursive: true });
+ renameSync(backup, target);
+ dropManifest();
+ log(`restored the previous ${basename(target)} — nothing was made worse`);
+ } catch (err) {
+ // Leave the backup AND its manifest in place: recoverOrphanedBackups()
+ // on the next run is the second chance.
+ log(`WARN: could not restore ${target} from ${backup}: ${err.message}`);
+ log(`WARN: the previous file is still at ${backup} — the next run will try again`);
+ }
+ },
+ };
+}
+
+/**
+ * Put back anything a previous run stashed but never restored — a run killed
+ * mid-download, or one whose restore() itself failed. Only acts when the target
+ * is currently absent, so it can never clobber a good binary.
+ */
+function recoverOrphanedBackups() {
+ if (!existsSync(BACKUP_DIR)) return;
+ let entries;
+ try {
+ entries = readdirSync(BACKUP_DIR);
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (!entry.endsWith(".json")) continue;
+ const manifest = join(BACKUP_DIR, entry);
+ const backup = manifest.slice(0, -".json".length);
+ try {
+ const { target } = JSON.parse(readFileSync(manifest, "utf8"));
+ if (!target || !existsSync(backup)) {
+ rmSync(manifest, RM_OPTS);
+ continue;
+ }
+ if (existsSync(target)) continue; // a good file is already there — leave it alone
+ mkdirSync(dirname(target), { recursive: true });
+ renameSync(backup, target);
+ rmSync(manifest, RM_OPTS);
+ log(`recovered ${basename(target)} left behind by an interrupted run`);
+ } catch (err) {
+ log(`WARN: could not process leftover backup ${entry}: ${err.message}`);
+ }
+ }
+}
+
+function cleanupBackupDir() {
+ try {
+ if (existsSync(BACKUP_DIR) && readdirSync(BACKUP_DIR).length === 0) {
+ rmSync(BACKUP_DIR, { recursive: true, force: true });
+ }
+ } catch {
+ /* ignore */
+ }
+}
+
+// ---------------------------------------------------------------------------
+// per-module results
+// ---------------------------------------------------------------------------
+
+/** status: "ok" | "repaired" | "failed" | "missing" */
+function makeResult(name, status, detail) {
+ return { name, required: REQUIRED.has(name), status, detail };
+}
+
+function buildFromSource(command) {
+ // stdout -> inherited (setup.bat sends it to the log), stderr -> inherited so
+ // compiler progress stays visible; npm's own output is far too noisy to buffer.
+ execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
+}
+
+function buildToolsHint() {
+ log("Install build tools first:");
+ log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
+ log(" Ubuntu/Debian: sudo apt install build-essential python3");
+ log(" CentOS/RHEL: sudo yum groupinstall 'Development Tools'");
+}
+
+// ---------------------------------------------------------------------------
+// ffmpeg-static (OPTIONAL — a system ffmpeg is a documented fallback)
+// ---------------------------------------------------------------------------
+
+async function ensureFfmpeg() {
+ const name = "ffmpeg-static";
+ const ffDir = join(NODE_MODULES, name);
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
- if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
- if (existsSync(ffDest)) {
- if (isValidSize(ffDest, 50 * 1024 * 1024)) {
- log("ffmpeg already exists, skipping");
- return true;
+ if (!existsSync(ffDir)) {
+ log(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`);
+ return makeResult(name, "missing", "package not installed");
+ }
+
+ if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) {
+ ensureExecutable(ffDest);
+ const probe = probeFfmpegBinary(ffDest);
+ if (probe.ok) {
+ log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`);
+ return makeResult(name, "ok", humanSize(ffDest));
}
- log("ffmpeg exists but seems corrupted (too small), re-downloading...");
+ // Deliberately NOT re-downloading here: ffmpeg is a plain executable with no
+ // ABI to mismatch, and forcing an ~80 MB re-download because `-version`
+ // could not be spawned would hurt exactly the slow-network users this
+ // script exists for.
+ log(`${name}: present (${humanSize(ffDest)}) but could not be executed: ${probe.error}`);
+ return makeResult(name, "ok", "present, not verified");
+ }
+
+ if (existsSync(ffDest)) {
+ log(`${name}: existing ffmpeg looks truncated (${humanSize(ffDest)}), re-downloading...`);
+ } else {
+ log(`${name}: ffmpeg binary missing, downloading...`);
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
- log("Downloading ffmpeg...");
- const buf = await download(url);
- await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
- try { execSync(`chmod +x "${ffDest}"`); } catch {}
- const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
- log(`ffmpeg OK (${size} MB)`);
- return true;
-}
-
-async function downloadOpus() {
- const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
- const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
- const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
-
- if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
- if (existsSync(opusDest)) {
- if (isValidSize(opusDest, 100 * 1024)) {
- log("@discordjs/opus already exists, skipping");
- return true;
- }
- log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
- }
-
- const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
- log("Downloading @discordjs/opus...");
+ const backup = stash(ffDest);
+ const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`;
try {
+ log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`);
const buf = await download(url);
- mkdirSync(dirname(opusDest), { recursive: true });
- const require = createRequire(import.meta.url);
- const tar = require("tar");
- const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
- writeFileSync(tmpFile, buf);
- await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
- log("@discordjs/opus OK");
- return true;
+ await pipeline(Readable.from(buf), createGunzip(), createWriteStream(tmpDest));
+ ensureExecutable(tmpDest);
+ if (sizeOf(tmpDest) < FFMPEG_MIN_BYTES) {
+ throw new Error(`downloaded ffmpeg is only ${humanSize(tmpDest)} — truncated`);
+ }
+ renameSync(tmpDest, ffDest); // atomic swap, same directory
+ backup.commit();
+ log(`${name}: OK (${humanSize(ffDest)})`);
+ return makeResult(name, "repaired", humanSize(ffDest));
} catch (err) {
- log(`CDN download failed (${err.message}), trying to build from source...`);
try {
- execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
- if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
- log("@discordjs/opus built from source OK");
- return true;
+ rmSync(tmpDest, { force: true });
+ } catch {
+ /* ignore */
+ }
+ backup.restore();
+ log(`${name}: download failed — ${err.message}`);
+ log(`${name}: not fatal — install ffmpeg system-wide and put it on PATH instead`);
+ return makeResult(name, "failed", err.message);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// @discordjs/opus (REQUIRED)
+// ---------------------------------------------------------------------------
+
+async function ensureOpus() {
+ const name = "@discordjs/opus";
+ const pkgDir = join(NODE_MODULES, "@discordjs", "opus");
+ const prebuildRoot = join(pkgDir, "prebuild");
+ // node-pre-gyp resolves this directory from the *running* Node's ABI, so a
+ // stale build for another ABI simply sits at another path and is ignored.
+ const prebuildDirName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
+ const destDir = join(prebuildRoot, prebuildDirName);
+
+ if (!existsSync(pkgDir)) {
+ log(`${name}: package not installed — run 'npm install' first`);
+ return makeResult(name, "missing", "package not installed");
+ }
+
+ const before = probeRequire(name);
+ if (before.ok) {
+ log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
+ return makeResult(name, "ok", `ABI ${NODE_ABI}`);
+ }
+ log(`${name}: unusable — ${describeProbe(before)}`);
+ log(`${name}: installing a build for ABI ${NODE_ABI}...`);
+
+ const version = readInstalledVersion(name) || "0.10.0";
+ const url =
+ `${CDN}/@discordjs/opus/v${version}/opus-v${version}` +
+ `-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
+
+ const backup = stash(destDir);
+ let staging = null;
+ try {
+ try {
+ log(`${name}: GET ${url}`);
+ const buf = await download(url);
+ staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
+ await extractTarGz(buf, staging);
+ const staged = join(staging, prebuildDirName);
+ if (!existsSync(join(staged, "opus.node"))) {
+ throw new Error(`tarball did not contain ${prebuildDirName}/opus.node`);
+ }
+ mkdirSync(prebuildRoot, { recursive: true });
+ rmSync(destDir, { recursive: true, force: true });
+ renameSync(staged, destDir); // atomic swap, same volume
+ log(`${name}: prebuilt binary installed`);
+ } catch (cdnErr) {
+ log(`${name}: CDN install failed (${cdnErr.message})`);
+ log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
+ buildFromSource(`npm rebuild ${name}`);
+ }
+
+ const after = probeRequire(name);
+ if (!after.ok) throw new Error(describeProbe(after));
+ backup.commit();
+ log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI})`);
+ return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
+ } catch (err) {
+ backup.restore();
+ log(`${name}: FAILED — ${err.message}`);
+ buildToolsHint();
+ return makeResult(name, "failed", err.message);
+ } finally {
+ if (staging) {
+ try {
+ rmSync(staging, { recursive: true, force: true });
+ } catch {
+ /* ignore */
}
- log("Source build completed but .node file not found");
- return false;
- } catch (buildErr) {
- log(`Source build failed: ${buildErr.message}`);
- log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
- log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
- return false;
}
}
}
-async function downloadBetterSqlite3() {
- const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
+// ---------------------------------------------------------------------------
+// better-sqlite3 (REQUIRED) — the module the ABI bug actually bites
+// ---------------------------------------------------------------------------
+
+async function ensureBetterSqlite3() {
+ const name = "better-sqlite3";
+ const pkgDir = join(NODE_MODULES, name);
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
- if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
- if (existsSync(dest)) {
- if (isValidSize(dest, 500 * 1024)) {
- log("better-sqlite3 already exists, skipping");
- return true;
- }
- log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
+ if (!existsSync(pkgDir)) {
+ log(`${name}: package not installed — run 'npm install' first`);
+ return makeResult(name, "missing", "package not installed");
}
- const version = "12.8.0";
- const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
- log("Downloading better-sqlite3...");
- const buf = await download(url);
- const require = createRequire(import.meta.url);
- const tar = require("tar");
- const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
- writeFileSync(tmpFile, buf);
- mkdirSync(dirname(dest), { recursive: true });
- await tar.extract({ cwd: pkgDir, file: tmpFile });
- if (existsSync(dest)) {
- log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
- return true;
+ const before = probeRequire(name);
+ if (before.ok) {
+ log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
+ return makeResult(name, "ok", `ABI ${NODE_ABI}`);
+ }
+ // This is the case the old size check could not see: the file is there, it is
+ // ~1.9 MB, and it is completely useless because it targets another ABI.
+ log(`${name}: unusable — ${describeProbe(before)}`);
+ log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`);
+
+ const version = readInstalledVersion(name) || "12.11.1";
+ const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
+
+ const backup = stash(dest);
+ let staging = null;
+ try {
+ try {
+ log(`${name}: GET ${url}`);
+ const buf = await download(url);
+ staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
+ await extractTarGz(buf, staging);
+ const staged = join(staging, "build", "Release", "better_sqlite3.node");
+ if (!existsSync(staged)) {
+ throw new Error("tarball did not contain build/Release/better_sqlite3.node");
+ }
+ mkdirSync(dirname(dest), { recursive: true });
+ rmSync(dest, { force: true });
+ renameSync(staged, dest); // atomic swap, same volume
+ log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
+ } catch (cdnErr) {
+ log(`${name}: CDN install failed (${cdnErr.message})`);
+ log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
+ buildFromSource(`npm rebuild ${name} --build-from-source`);
+ }
+
+ const after = probeRequire(name);
+ if (!after.ok) throw new Error(describeProbe(after));
+ backup.commit();
+ log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI}, ${humanSize(dest)})`);
+ return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
+ } catch (err) {
+ backup.restore();
+ log(`${name}: FAILED — ${err.message}`);
+ buildToolsHint();
+ return makeResult(name, "failed", err.message);
+ } finally {
+ if (staging) {
+ try {
+ rmSync(staging, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+ }
}
- log("better-sqlite3 extracted but .node file not found at expected path");
- return false;
}
+// ---------------------------------------------------------------------------
+// stamp
+// ---------------------------------------------------------------------------
+
+/** Record which ABI this install was built for. Lives inside node_modules so it
+ * dies together with the thing it describes. check-native.mjs reads it. */
+function writeStamp(results) {
+ if (!existsSync(NODE_MODULES)) return;
+ const stamp = {
+ abi: NODE_ABI,
+ nodeVersion: process.version,
+ platform: PLATFORM,
+ arch: ARCH,
+ updatedAt: new Date().toISOString(),
+ modules: Object.fromEntries(results.map((r) => [r.name, r.status])),
+ };
+ try {
+ writeFileSync(STAMP_FILE, `${JSON.stringify(stamp, null, 2)}\n`);
+ log(`ABI stamp written: node_modules/.tsmusicbot-abi (Node ${process.version}, ABI ${NODE_ABI})`);
+ } catch (err) {
+ log(`WARN: could not write ABI stamp: ${err.message}`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// main
+// ---------------------------------------------------------------------------
+
+const STEPS = [
+ ["ffmpeg-static", ensureFfmpeg],
+ ["@discordjs/opus", ensureOpus],
+ ["better-sqlite3", ensureBetterSqlite3],
+];
+
try {
- const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
- if (results.some(Boolean)) {
- console.log(" [binary] All downloads complete");
+ log(`Node ${process.version} (ABI ${NODE_ABI}), ${PLATFORM}-${ARCH}, CDN ${CDN}`);
+
+ recoverOrphanedBackups();
+
+ // STRICTLY SEQUENTIAL, and it has to stay that way. The source-build fallback
+ // shells out through execSync, which parks the event loop for minutes; the
+ // 120s timeout that download() arms is a socket-INACTIVITY timer sitting on
+ // 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
+ // 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
+ // better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node
+ // versions this project supports, one module 404s within ~100ms and starts
+ // 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
+ // reported success — leaving the user with no ffmpeg and no working playback.
+ // Nothing here benefits from overlap anyway: every probe is execFileSync.
+ const results = [];
+ for (const [name, run] of STEPS) {
+ try {
+ results.push(await run());
+ } catch (err) {
+ results.push(makeResult(name, "failed", err?.message ?? String(err)));
+ }
+ }
+
+ cleanupBackupDir();
+
+ log("");
+ log(`Summary — Node ${process.version} / ABI ${NODE_ABI} / ${PLATFORM}-${ARCH}:`);
+ for (const r of results) {
+ const tag =
+ r.status === "ok"
+ ? "OK"
+ : r.status === "repaired"
+ ? "REPAIRED"
+ : r.required
+ ? "FAILED"
+ : "WARN (optional)";
+ log(` - ${r.name.padEnd(17)} ${tag}${r.detail ? ` ${r.detail}` : ""}`);
+ }
+
+ const broken = results.filter(
+ (r) => r.required && r.status !== "ok" && r.status !== "repaired",
+ );
+ // Only stamp a build that actually succeeded. The stamp says "node_modules is
+ // built for ABI X"; writing it after a failed repair would have check-native
+ // print a reassuring "built with ABI 137" right above its own "this module is
+ // built for ABI 127" complaint.
+ if (broken.length === 0) writeStamp(results);
+ // process.exitCode rather than process.exit(): setup.bat redirects stdout to
+ // setup.log, and process.exit() can drop output that has not flushed yet.
+ if (broken.length > 0) {
+ log("");
+ log(`ERROR: required native module(s) unusable: ${broken.map((r) => r.name).join(", ")}`);
+ log("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。");
+ process.exitCode = 1;
+ } else {
+ log("All required native modules are ready.");
+ process.exitCode = 0;
}
} catch (e) {
- console.error(` [binary] ERROR: ${e.message}`);
- process.exit(1);
+ log(`ERROR: ${e.stack || e.message}`);
+ process.exitCode = 1;
}
-
diff --git a/scripts/setup.bat b/scripts/setup.bat
index e43435c..e1fb536 100644
--- a/scripts/setup.bat
+++ b/scripts/setup.bat
@@ -10,8 +10,11 @@ title TSMusicBot Setup
:: - 自动修复 PowerShell 环境变量
:: ============================================================
-set "SCRIPT_VERSION=2.1"
+set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=20"
+:: 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.
+set "TESTED_NODE_MAJOR=22"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
@@ -64,13 +67,38 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
-if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
- call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
+:: The supported floor is not just a major version, so let node decide:
+:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs
+:: >=20.17 / >=22.9, and the odd majors (21 / 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]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
+if errorlevel 1 (
+ call :error "Node.js %NODE_VER% is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
+ echo Download: https://nodejs.org/ or https://nodejs.cn/
pause
exit /b 1
)
+
+:: Not fatal: setup now rebuilds the native modules for whatever ABI you run,
+:: so newer Node majors work - they are just slower to install.
+:: NOTE: keep every line inside these parenthesised blocks pure ASCII.
+:: cmd.exe mis-tracks its file offset when a block contains multi-byte UTF-8
+:: characters and starts eating the "echo " prefix of following lines.
+:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
+if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
+ echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22.
+ 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 Recommended: Node 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
+ 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%"
+)
echo.
+:: Native addons are tied to one Node ABI. If node_modules was built by a
+:: different Node major, step 4b below detects it and repairs it.
+call :log "Node ABI for this install: see node_modules\.tsmusicbot-abi after step 4b"
+
:: ============================================================
:: Step 2: Check npm
:: ============================================================
@@ -143,16 +171,40 @@ echo [OK] Backend dependencies installed.
echo.
:: ============================================================
-:: Step 4b: Download native binaries from CDN
+:: Step 4b: Verify / download / repair native binaries (ABI aware)
:: ============================================================
-call :step "4b/7" "Downloading native binaries"
+call :step "4b/7" "Checking native binaries"
-node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
-if errorlevel 1 (
- echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
-) else (
- echo [OK] Native binaries installed.
+echo Verifying native modules for %NODE_VER% and downloading whatever is missing.
+echo Progress is shown below; the full transcript goes to the log file.
+echo.
+
+:: The .mjs writes progress to stderr and - with TSMB_BINARY_LOG_STDOUT=1 - the
+:: same lines to stdout. Redirecting only stdout therefore keeps the log complete
+:: while the user still sees live progress instead of a frozen window.
+set "TSMB_BINARY_LOG_STDOUT=1"
+node scripts\download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%"
+set "BIN_RESULT=!errorlevel!"
+set "TSMB_BINARY_LOG_STDOUT="
+
+:: ASCII only inside these blocks - see the note near the Node version check.
+if not "!BIN_RESULT!"=="0" (
+ set "FAILED=1"
+ call :error "A required native module is unusable - see the [binary] lines above."
+ echo Required: @discordjs/opus and better-sqlite3.
+ echo Full log: %LOG_FILE%
)
+
+if "!FAILED!"=="1" (
+ echo.
+ echo Setup aborted. Fix the problem above and run this script again.
+ call :log "Setup aborted at step 4b"
+ pause
+ exit /b 1
+)
+
+echo [OK] Native binaries ready for %NODE_VER%.
+echo ABI recorded in node_modules\.tsmusicbot-abi
echo.
:: ============================================================
diff --git a/scripts/setup.sh b/scripts/setup.sh
index dcd0bde..d54bf44 100755
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -27,6 +27,26 @@ if ! command -v node &>/dev/null; then
fi
echo "[OK] Node.js $(node -v)"
+# 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.
+TESTED_NODE_MAJOR=22
+NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
+
+# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client
+# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors
+# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines".
+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
+ echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
+ echo " https://nodejs.org/ | https://nodejs.cn/"
+ exit 1
+fi
+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 " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
+ echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
+ echo " This is only a warning - setup builds the binaries for $(node -v) either way."
+fi
+
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
@@ -73,15 +93,30 @@ npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_F
echo "[OK] Dependencies installed."
echo ""
-# ---- Step 2: Download native binaries from CDN ----
-echo "---- 2/5: Downloading native binaries ----"
+# ---- Step 2: Verify / download / repair native binaries (ABI aware) ----
+echo "---- 2/5: Checking native binaries ----"
echo ""
-if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
- echo "[OK] Native binaries installed."
-else
- echo "[WARN] Some native binaries had issues (will try source build as fallback)."
+# The old `if node ... | tee ...` only printed a [WARN] and carried on, so a
+# broken native module still produced a "Setup Complete!" banner. It also read
+# the *pipeline's* status: `set -o pipefail` above happens to surface node's
+# failure, but a failing `tee` (unwritable log) was indistinguishable from a
+# failing node. PIPESTATUS[0] is exactly node's own exit code, nothing else.
+set +e
+node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"
+BIN_STATUS=${PIPESTATUS[0]}
+set -e
+
+if [ "$BIN_STATUS" -ne 0 ]; then
+ echo ""
+ echo "[ERROR] A required native module (@discordjs/opus / better-sqlite3) is unusable."
+ echo " 必需的原生模块不可用,安装中止。原因见上面的 [binary] 输出。"
+ echo " Log: $LOG_FILE"
+ exit 1
fi
+# ffmpeg-static failures are only a WARN inside the script above (a system
+# ffmpeg on PATH is a supported fallback), so reaching here means we are good.
+echo "[OK] Native binaries ready for $(node -v)."
echo ""
# ---- Step 3: Install web panel dependencies ----
diff --git a/scripts/start.bat b/scripts/start.bat
index 26fc9c7..3307b72 100644
--- a/scripts/start.bat
+++ b/scripts/start.bat
@@ -29,6 +29,19 @@ if not exist "dist" (
exit /b 1
)
+:: Preflight: do the compiled native modules match THIS Node version?
+:: Switching Node majors after setup leaves node_modules built for the old ABI;
+:: without this check the bot dies mid-startup with a NODE_MODULE_VERSION stack.
+:: check-native.mjs prints the bilingual explanation itself; keep the lines in
+:: this block pure ASCII (cmd.exe garbles multi-byte text inside blocks).
+node scripts\check-native.mjs
+if errorlevel 1 (
+ echo.
+ echo Please run scripts\setup.bat to rebuild the native modules.
+ pause
+ exit /b 1
+)
+
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
@@ -38,7 +51,10 @@ if errorlevel 1 (
)
:: Start the application
+echo WebUI: http://localhost:3000
+echo Press Ctrl+C to stop.
+echo.
node dist/index.js
pause
-
+
diff --git a/start.bat b/start.bat
index dd63003..446d458 100644
--- a/start.bat
+++ b/start.bat
@@ -1,19 +1,24 @@
@echo off
-title TSMusicBot
+:: ============================================================
+:: TSMusicBot - convenience launcher at the repo root.
+:: Everything real lives in scripts\start.bat; this file only makes sure we
+:: run from the project directory and then delegates, so both entry points
+:: behave identically (same node/dist checks, same native-module preflight).
+:: ============================================================
-:: Check node
-where node >nul 2>&1
-if errorlevel 1 (
- echo Node.js not found. Run scripts\setup.bat first.
+cd /d "%~dp0" || (
+ echo [FATAL] Cannot change to the project directory.
pause
exit /b 1
)
-echo Starting TSMusicBot...
-echo WebUI: http://localhost:3000
-echo Press Ctrl+C to stop.
-echo.
+:: Keep lines inside parenthesised blocks pure ASCII: cmd.exe mis-tracks its
+:: file offset when a block contains multi-byte UTF-8 and eats the "echo " prefix.
+if not exist "scripts\start.bat" (
+ echo scripts\start.bat not found - is this the TSMusicBot project folder?
+ pause
+ exit /b 1
+)
-node dist\index.js
-
-pause
\ No newline at end of file
+call "scripts\start.bat"
+exit /b %errorlevel%