From 846ee30bce4fd895263e1d839c1b1bc5c133d7d2 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 17 Jul 2026 00:24:37 +0800 Subject: [PATCH] fix(qq): pin the QQ Music API sidecar to qqMusicApiPort The embedded QQ Music API sidecar could bind a different port than the one the client base URL (getQQMusicBaseUrl) targets. The upstream @sansenjian/qq-music-api package derives its default port from process.env.PORT (falling back to 3200) and, in some historical versions, auto-started that server as an import side effect. When an old build listened on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login / cookie persistence silently broke. Align process.env.PORT with the configured qqMusicApiPort for the duration of the import (restoring the previous value afterwards so nothing else in the process is affected), reuse an already-listening instance instead of racing a second listen, and log the port actually bound (read from the socket) so any mismatch is visible in the logs. - src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log - src/music/api-server.test.ts: regression coverage that the sidecar follows qqMusicPort (not an injected PORT) and restores PORT afterwards - README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort and points stale-latest-image users (who saw 3300) at re-pulling the image Co-Authored-By: Claude Fable 5 --- README.md | 7 ++- src/music/api-server.test.ts | 107 ++++++++++++++++++++++++++++++++++- src/music/api-server.ts | 56 ++++++++++++++---- 3 files changed, 156 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7731821..5e413ec 100644 --- a/README.md +++ b/README.md @@ -788,10 +788,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 **Q:端口 3200 被占用?** -A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 +A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。 + +**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)** +A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。 **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** -A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: +A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 修好版本后重新 `npm install && npm run build` 并重启即可。 diff --git a/src/music/api-server.test.ts b/src/music/api-server.test.ts index c0309ce..6b8067d 100644 --- a/src/music/api-server.test.ts +++ b/src/music/api-server.test.ts @@ -1,5 +1,34 @@ -import { describe, it, expect } from "vitest"; -import { describeQqApiStartupError } from "./api-server.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createApiServerManager, describeQqApiStartupError } from "./api-server.js"; +import type { Logger } from "../logger.js"; + +// Record every listen() the QQ sidecar makes so we can assert it is always +// pinned to the configured port (regression coverage for issue #122). +const mockState = vi.hoisted(() => ({ + listenCalls: [] as Array<{ port: number; host: string }>, +})); + +vi.mock("@sansenjian/qq-music-api", () => { + const app = { + listen(port: number, host: string, cb?: () => void) { + mockState.listenCalls.push({ port, host }); + const server = { + address: () => ({ port, address: host, family: "IPv4" as const }), + on() { + return server; + }, + close(done?: () => void) { + done?.(); + }, + }; + // Real net/Koa fire the listening callback on a later tick, after the + // caller has captured the returned server handle. + if (cb) setImmediate(cb); + return server; + }, + }; + return { default: app }; +}); describe("describeQqApiStartupError", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { @@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => { expect(describeQqApiStartupError(null)).toBeNull(); }); }); + +// Regression coverage for issue #122: the QQ Music API sidecar must listen on +// the same port the client base URL targets (config.qqMusicApiPort). A stale +// build once bound 3300 while the client requested 3200, silently breaking the +// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200. +describe("createApiServerManager — QQ sidecar port binding", () => { + const noopLogger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + } as unknown as Logger; + + beforeEach(() => { + mockState.listenCalls = []; + }); + + it("listens on the configured qqMusicPort and exposes a matching base URL", async () => { + const port = 39217; // uncommon port to avoid clashing with a real instance + const manager = createApiServerManager( + { neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + await manager.start(); + manager.stop(); + + expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`); + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + }); + + it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => { + const port = 39219; + const previous = process.env.PORT; + // Simulate a hosting platform / compose file injecting a stray PORT that + // must NOT leak into the QQ sidecar's chosen port. + process.env.PORT = "39999"; + const manager = createApiServerManager( + { neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // The sidecar follows qqMusicPort, never the injected PORT. + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + // The injected PORT is restored so nothing else in the process is affected. + expect(process.env.PORT).toBe("39999"); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); + + it("leaves an absent PORT env unset after importing the sidecar", async () => { + const port = 39221; + const previous = process.env.PORT; + delete process.env.PORT; + const manager = createApiServerManager( + { neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // Was unset before importing — must be unset again, no leaked override. + expect(process.env.PORT).toBeUndefined(); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); +}); diff --git a/src/music/api-server.ts b/src/music/api-server.ts index 08fe4b8..1374268 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -114,7 +114,25 @@ export function createApiServerManager( "QQ Music API port already in use — reusing existing instance" ); } else { - const qqModule = (await import("@sansenjian/qq-music-api")) as any; + // Pin the upstream server to the configured port before importing. + // The package derives its default port from process.env.PORT (falling + // back to 3200) and, in some historical versions, auto-started that + // server as an import side effect. Aligning PORT with qqMusicApiPort + // guarantees the sidecar can never bind a different port than the one + // the client base URL (getQQMusicBaseUrl) targets — the root cause of + // issue #122, where an old build listened on 3300 while the client + // requested 3200. Restore the previous value right after import so we + // never leak the override into the rest of the process (e.g. the web + // server or the NetEase sidecar, which also read PORT as a fallback). + const prevPortEnv = process.env.PORT; + process.env.PORT = String(options.qqMusicPort); + let qqModule: any; + try { + qqModule = (await import("@sansenjian/qq-music-api")) as any; + } finally { + if (prevPortEnv === undefined) delete process.env.PORT; + else process.env.PORT = prevPortEnv; + } // 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 @@ -124,16 +142,34 @@ export function createApiServerManager( ? 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) + // A version that auto-started on import has already bound the + // configured port (thanks to the PORT alignment above); reuse it + // rather than racing a second listen that would fail EADDRINUSE. + const stillFree = await isPortFree(options.qqMusicPort); + if (!stillFree) { + logger.info( + { port: options.qqMusicPort }, + "QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance" ); - srv.on("error", reject); - }); - logger.info( - { port: options.qqMusicPort }, - "QQ Music API started" - ); + } else { + qqMusicServer = await new Promise((resolve, reject) => { + const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => + resolve(srv) + ); + srv.on("error", reject); + }); + // Log the port actually bound (read from the socket) rather than + // the requested one, so operators can spot a mismatch in the logs. + const addr = qqMusicServer.address(); + const boundPort = + addr && typeof addr === "object" && addr !== null + ? addr.port + : options.qqMusicPort; + logger.info( + { port: boundPort }, + "QQ Music API started" + ); + } } else { logger.warn("QQ Music API module does not expose a Koa app"); }