Files
teamspeak-music-bot/src/music/api-server.ts
T
saopig1andClaude Opus 4.8 08260182a9 fix(qq): pin @sansenjian/qq-music-api to ~2.4.0 + guard startup failures
A loose `^2.2.10` range let npm pull a newer build of the QQ Music API. The
library went ESM in 2.3.x: an ESM-only 2.3.0/2.3.1 throws ERR_REQUIRE_ESM on
load, so the embedded server never binds port 3200 and every QQ request
(including /getQQLoginQr) fails downstream with ECONNREFUSED — the QR code never
appears and cookies look broken.

- Pin to `~2.4.0` (verified: loads via the bot's native ESM import, exposes the
  Koa app, and every endpoint qq.ts calls returns the exact shapes it parses —
  QR, recommend, lyric, play, playlist detail). Blocks the broken 2.3.0/2.3.1
  and any future 2.5 migration. NOTE: 2.4.x requires Node >=20.17 (or >=22.9).
- Add describeQqApiStartupError(): on startup failure, log an actionable error
  (ERR_REQUIRE_ESM → version-pin hint; engine mismatch → Node-upgrade hint)
  instead of a generic warning, so this is obvious from the logs next time.
- README: troubleshooting entry for "QQ 二维码不弹 / 登录失败 / cookie 无法使用"
  and the version/Node note in the dependency table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:05:47 +08:00

165 lines
5.7 KiB
TypeScript

import net from "node:net";
import type { Logger } from "../logger.js";
import type { Server } from "node:http";
export interface ApiServerOptions {
neteasePort: number;
qqMusicPort: number;
}
export interface ApiServerManager {
start(): Promise<void>;
stop(): void;
getNeteaseBaseUrl(): string;
getQQMusicBaseUrl(): string;
}
/**
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
* actionable operator guidance, or null when it isn't a recognised
* dependency/runtime mismatch. Exported for testing.
*
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
* that needs Node >=20.17 — either way the embedded server never binds, so
* every QQ request fails downstream with ECONNREFUSED on the API port.
*/
export function describeQqApiStartupError(err: unknown): string | null {
const e = (err ?? {}) as { code?: string; message?: string };
const code = String(e.code ?? "");
const msg = String(e.message ?? "");
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
return (
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
);
}
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
}
return null;
}
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => {
server.close(() => resolve(false));
});
server.once("listening", () => {
server.close(() => resolve(true));
});
server.listen(port, "127.0.0.1");
});
}
export function createApiServerManager(
options: ApiServerOptions,
logger: Logger
): ApiServerManager {
let neteaseServer: Server | null = null;
let qqMusicServer: Server | null = null;
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
return {
async start(): Promise<void> {
logger.info("Starting embedded music API servers...");
// Start NetEase Cloud Music API
try {
const portFree = await isPortFree(options.neteasePort);
if (!portFree) {
logger.info(
{ port: options.neteasePort },
"NetEase API port already in use — reusing existing instance"
);
} else {
const ncmModule = await import("NeteaseCloudMusicApi") as any;
const serverObj = ncmModule.server ?? ncmModule.default?.server;
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
neteaseServer = app;
logger.info(
{ port: options.neteasePort },
"NetEase Cloud Music API started"
);
}
} catch (err) {
logger.error({ err }, "Failed to start NetEase Cloud Music API");
}
// Start QQ Music API. Older versions auto-started on import; the
// current fork (2.2.11+) only listens when run as `require.main`,
// so we explicitly call .listen() on the imported Koa app and keep
// the server handle for clean shutdown.
try {
const portFree = await isPortFree(options.qqMusicPort);
if (!portFree) {
logger.info(
{ port: options.qqMusicPort },
"QQ Music API port already in use — reusing existing instance"
);
} else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
// The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app
// older: module itself may be the Koa app
const candidate = qqModule.default ?? qqModule;
const koaApp = typeof candidate.listen === "function"
? candidate
: candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv)
);
srv.on("error", reject);
});
logger.info(
{ port: options.qqMusicPort },
"QQ Music API started"
);
} else {
logger.warn("QQ Music API module does not expose a Koa app");
}
}
} catch (err) {
const hint = describeQqApiStartupError(err);
if (hint) {
logger.error(
{ err },
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
);
} else {
logger.warn(
{ err },
"QQ Music API not available — QQ Music features may be limited"
);
}
}
},
stop(): void {
logger.info("Stopping music API servers");
if (neteaseServer && typeof (neteaseServer as any).close === "function") {
(neteaseServer as any).close();
}
neteaseServer = null;
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
(qqMusicServer as any).close();
}
qqMusicServer = null;
},
getNeteaseBaseUrl(): string {
return neteaseBaseUrl;
},
getQQMusicBaseUrl(): string {
return qqMusicBaseUrl;
},
};
}