fix(setup): 按 Node ABI 校验并自动修复原生模块

换过 Node 大版本之后安装就废了,而且安装脚本还会报告成功。原生模块只能在
编译它的那个 Node ABI 上加载(Node 20 = 115、22 = 127、24 = 137),而
better-sqlite3 的 .node 放在与 ABI 无关的固定路径下,旧的 download-binaries
只检查「文件存在且大于 500KB」,于是给 Node 24 编译的 1.9MB 文件在 Node 22
下原样保留,跳过重新下载,机器人启动时死在 NODE_MODULE_VERSION 上。
(@discordjs/opus 的目录名里带 ABI,反而歪打正着没这个问题。)

download-binaries.mjs 现在不看文件大小,而是在子进程里真的把每个包 load 一遍
——子进程是必须的,Windows 上父进程加载过的 .node 会一直被映射,系统随后拒绝
删除或覆盖它。注意 better-sqlite3 的 addon 是在 Database 构造函数里惰性加载的,
所以光 require 这个包探测不出问题,得真的开一个内存库。

失败就按当前 ABI 重新安装,整个替换过程是先把旧文件挪到 node_modules/
.tsmusicbot-backup、下载解压到暂存目录、原子 rename 就位、再探测一次,任何
一步失败都把原文件还原回去——删掉不匹配的二进制却下载不下来,比原来的版本
更糟。备份特意放在包的 build/ 之外,因为源码编译回退会调 node-gyp 把 build/
清空。被中断(比如下载到一半 Ctrl+C)遗留的备份,下一次运行会自动认领回来。

其他一并修掉的问题:
- 版本号原本硬编码 12.8.0,实际锁的是 12.11.1,一旦真的触发下载就会 404;
  改为从 node_modules 里读。
- 三个模块原本用 Promise.all 并发。源码编译走的是 execSync,会把事件循环整个
  卡住几分钟,而 download() 的 120 秒超时是挂在同一个循环上的 socket 静默计时
  器——循环一恢复,还在传输中的连接就会被判超时。这不是小概率竞态:npmmirror
  上没有 ABI 137 的 opus,也没有 ABI 115 的 better-sqlite3,所以在 Node 24 和
  Node 20 上必定有一个模块在 100ms 内 404 并开始编译,而 ffmpeg 的 80MB 下载
  正在进行。ffmpeg 是可选模块,于是它被误杀后只记一条 WARN,脚本照样 exit 0,
  setup 打印「Setup Complete」,用户装完却没有 ffmpeg,放什么都放不出来。
  改成严格串行执行。
- 必需模块(opus / better-sqlite3)失败才返回非零;ffmpeg 有系统 ffmpeg 兜底,
  只警告。setup.bat 里原本形同虚设的 FAILED 标志接上了,必需模块失败会中止安装,
  不再是「装完才发现」。
- 4b 步骤原本把全部输出重定向进 setup.log,用户盯着不动的窗口以为卡死;现在
  进度走 stderr 实时显示,完整记录仍进日志。

新增 scripts/check-native.mjs:启动前预检,直接说清楚哪个模块对不上、分别是哪
个 ABI、怎么修,而不是抛一串 NODE_MODULE_VERSION 堆栈。scripts\start.bat、根目
录 start.bat(现在改为委托给前者,并且会先切到项目目录)和 npm start 的 prestart
都会跑它。Docker 运行镜像也补上这个文件,否则容器里执行 npm start 会因为找不到
脚本而失败。

Node 版本要求改为按依赖的真实下限判断(@honeybbq/teamspeak-client 要 >=20.19、
@sansenjian/qq-music-api 要 >=20.17/22.9,21 和 23 被 better-sqlite3 与 vitest
排除),package.json 补上对应的 engines;比 20/22 LTS 更新的大版本不阻止,只提
示可能要源码编译。README 相应更新,并补一条 NODE_MODULE_VERSION 的常见问题。

注意:批处理里新增的行全部保持纯 ASCII —— cmd.exe 在括号块里遇到多字节 UTF-8
会算错文件偏移,开始吃掉后续行的 echo 前缀,中文提示一律交给 Node 脚本输出。

Closes #140

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 5 committed 2026-08-09 15:09:15 +08:00
1 parent 4e4354282d
commit 03ffd09d36
9 files changed
+968 -135

No files matched your search

+150
View File
@@ -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;
}
+5
View File
@@ -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
+654 -97
View File
@@ -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;
}
+62 -10
View File
@@ -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.
:: ============================================================
+41 -6
View File
@@ -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 ----
+17 -1
View File
@@ -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