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; 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 { 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 { 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((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; }, }; }