From 3aa06006fe5376858402e7cab0ff679190a111a4 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 21:07:26 +0800 Subject: [PATCH 1/2] fix(qq): repair QR code login flow against @sansenjian/qq-music-api 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR login against QQ Music has been silently broken: every call to checkQrCodeStatus returned "expired", so the scan-and-confirm cycle never completed even when the user successfully scanned the code. The root cause was four independent bugs in our wrapper talking past the library's actual HTTP shape. 1. getQrCode lost ptqrtoken. /getQQLoginQr returns { img, qrsig, ptqrtoken }, but we stored only one of them in the single `key` field (picking qrsig, falling back to ptqrtoken). The polling endpoint needs BOTH — passing only one fails with 400 "参数错误". Fix: pack both into the opaque `key` as "qrsig|ptqrtoken" and split on the receive side. 2. checkQrCodeStatus used GET. @sansenjian/qq-music-api 2.x registers /checkQQLoginQr as POST only (router.js: `router.post('/checkQQLoginQr', ...)`). GET returns 405 Method Not Allowed, axios throws, the catch returns "expired". Fix: api.post(url, null, { params }). 3. checkQrCodeStatus parsed the wrong response shape. The endpoint uses customResponse, not successResponse, so axios sees the body directly (no { response: ... } wrapper). The actual shape for each state is: waiting: { isOk: false, refresh: false, message: '未扫描二维码' } expired: { isOk: false, refresh: true, message: '二维码已失效' } success: { isOk: true, message: '登录成功', session: { cookie } } We were looking for a numeric `code === 0/1/2` field that does not exist, so every state fell through to "expired". Fix: switch on isOk / refresh / message. 4. Cookie read from the wrong path on success. On isOk=true the cookie lives at res.data.session.cookie, not res.data.cookie — so even if everything else had worked, the cookie would never have been saved. Fix: read session.cookie. Also rewrites getAuthStatus to actually validate the cookie: 5. getAuthStatus hit a non-validating endpoint. /getUserAvatar is not registered on the library's main router; the real route is /user/getUserAvatar, and even that just builds a static avatar URL from a uin without round-tripping through QQ Music with the cookie. The result: the bot happily persisted any user-supplied cookie to disk and sent it on every request while every downstream login check returned "not logged in". Fix: parse uin from the cookie, call /user/getUserPlaylists (which actually hits QQ Music with the cookie), and derive the avatar URL from the uin via q.qlogo.cn/headimg_dl. This is the same getAuthStatus fix that was sitting on the claude/bot-shutdown-disconnect-cwFvF branch, now combined with the QR login repairs. Verification: - tsc --noEmit clean - vitest: 93/93 pass - Live: POST /api/auth/qrcode platform=qq returns both tokens packed into `key`; polling a freshly-issued QR returns {"status":"waiting"} instead of the old {"status":"expired"}; raw library response is {"isOk":false,"refresh":false,"message":"未扫描二维码"} as expected. - Regression: netease and bilibili QR flows still produce non-empty qrUrl/key — no collateral damage to the other providers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/music/qq.ts | 77 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/music/qq.ts b/src/music/qq.ts index 4b9d9e7..064fff7 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -152,30 +152,53 @@ export class QQMusicProvider implements MusicProvider { } async getQrCode(): Promise { + // @sansenjian/qq-music-api 2.x returns { img, qrsig, ptqrtoken } via + // customResponse (no { response: ... } wrapping). /checkQQLoginQr + // requires BOTH qrsig AND ptqrtoken — passing only one gives a 400 + // "参数错误". Pack both into the opaque `key` field so the polling + // endpoint can split them back out. Separator "|" is safe: QQ tokens + // are alphanumeric. const res = await this.api.get("/getQQLoginQr"); + const qrsig: string = res.data?.qrsig ?? ""; + const ptqrtoken: string = String(res.data?.ptqrtoken ?? ""); return { qrUrl: "", qrImg: res.data?.img ?? "", - key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "", + key: `${qrsig}|${ptqrtoken}`, }; } async checkQrCodeStatus( key: string ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { - const res = await this.api.get("/checkQQLoginQr", { - params: { qrsig: key }, - }); - const code = res.data?.code ?? res.data?.response?.code; - if (code === 0) { - if (res.data?.cookie) { - this.cookie = res.data.cookie; - } + const [qrsig, ptqrtoken] = key.split("|"); + if (!qrsig || !ptqrtoken) return "expired"; + + // NOTE: /checkQQLoginQr is registered as POST only in + // @sansenjian/qq-music-api 2.x. GET returns 405 Method Not Allowed. + let res; + try { + res = await this.api.post("/checkQQLoginQr", null, { + params: { qrsig, ptqrtoken }, + }); + } catch { + return "expired"; + } + + // customResponse shape: + // success: { isOk: true, message: '登录成功', session: { cookie, ... } } + // scanning: { isOk: false, refresh: false, message: '未扫描二维码' } + // expired: { isOk: false, refresh: true, message: '二维码已失效' } + const body = res.data; + if (body?.isOk === true) { + const cookie: string = body.session?.cookie ?? ""; + if (cookie) this.cookie = cookie; return "confirmed"; } - if (code === 1) return "scanned"; - if (code === 2) return "waiting"; - return "expired"; + if (body?.refresh === true) return "expired"; + if (typeof body?.message === "string" && body.message.includes("未扫描")) + return "waiting"; + return "waiting"; } setCookie(cookie: string): void { @@ -188,20 +211,30 @@ export class QQMusicProvider implements MusicProvider { async getAuthStatus(): Promise { if (!this.cookie) return { loggedIn: false }; + // /getUserAvatar in @sansenjian/qq-music-api 2.x is NOT registered on + // the main router; the real endpoint is /user/getUserAvatar, and even + // that just builds a static URL from a uin without validating the + // cookie against QQ. Round-trip through /user/getUserPlaylists which + // actually hits QQ Music with the cookie; if we get playlists back, + // the cookie is valid. Derive nickname/avatar from the uin parsed out + // of the cookie. try { - const res = await this.api.get("/getUserAvatar", { + const uinMatch = /\buin=o?0?(\d+)/.exec(this.cookie); + const uin = uinMatch ? uinMatch[1] : ""; + const res = await this.api.get("/user/getUserPlaylists", { params: { ...this.cookieParams }, }); - if (res.data?.response?.data) { - return { - loggedIn: true, - nickname: res.data.response.data.nickname, - avatarUrl: res.data.response.data.headpic, - }; - } + const ok = res.data?.response?.data || res.data?.data; + if (!ok) return { loggedIn: false }; + return { + loggedIn: true, + nickname: uin ? `QQ ${uin}` : "QQ Music", + avatarUrl: uin + ? `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100` + : undefined, + }; } catch { - // ignore + return { loggedIn: false }; } - return { loggedIn: false }; } } From 5647cf6d361d56e7cbf4be17d366471a98435c4c Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 22:52:12 +0800 Subject: [PATCH 2/2] feat(qq): consume local @sansenjian/qq-music-api fork with VIP-aware getMusicPlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoints the `@sansenjian/qq-music-api` dependency from the public npm release to a local fork at ../qq-music-api, which ships a corrected getMusicPlay that: - drops the hardcoded-sign GET path (no longer honored by QQ's vkey server for VIP entitlement lookups) - POSTs JSON directly to u.y.qq.com/cgi-bin/musicu.fcg (mirroring the library's own getLyric.ts pattern) - extracts qqmusic_key from the forwarded cookie and passes it as `comm.authst` — the inline auth field the jsososo/QQMusicApi reference implementation sets - uses `ct: 19` (was 24) to match the community reference For accounts that actually have entitlement to a given track, this now returns the real VIP URL. For accounts that don't, QQ's vkey server still returns result=104003 with empty purl — this is correct server-side behavior and not a bug. Verified by observing the real QQ Music web player on y.qq.com fall back to the same 30-second preview on a logged-in account that lacks the specific track tier. Supporting changes: src/music/api-server.ts The fork (v2.2.11) stopped auto-starting a Koa server on import — it only listens when run as `require.main`. Explicitly import the default Koa app and call .listen() with a server handle we can clean up on shutdown. Without this fix, port 3200 silently fails to bind and every QQ endpoint 502s. src/music/qq.ts (getSongDetail) The library's /getSongInfo endpoint returns upstream code 500001 because its param format no longer matches QQ's current API. resolveAndPlay only needs `id` + `platform` to fetch a play URL, so fall through to a minimal stub on /getSongInfo failure. This unblocks /play-by-id and /add-by-id for QQ — they had been returning "Song not found" for every QQ track regardless of entitlement. scripts/qq_browser_login.py Visible-browser diagnostic tool that opens Chromium at y.qq.com, auto-detects login via uin cookie poll, captures the full post-login cookie set, tests it against /getMusicPlay for 稻香, and writes the cookie to data/cookies/qq.json only if VIP actually unlocks. On failure, dumps the full cookie to data/cookies/qq.browser-capture.json for OAuth-vs-browser diff. scripts/qq_verify_entitlement.py Companion diagnostic: opens the real QQ Music web player at a specific song's detail page so the user can manually click play and verify whether their account has entitlement — independent of any code path in this project. If the browser plays the full song, HTTP 104003 is a request-signing issue; if the browser also falls back to a 30-second preview, the account lacks the tier/album purchase and no code fix can change that. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 345 ++++++------------------------- package.json | 2 +- scripts/qq_browser_login.py | 212 +++++++++++++++++++ scripts/qq_verify_entitlement.py | 119 +++++++++++ src/music/api-server.ts | 31 ++- src/music/qq.ts | 48 +++-- 6 files changed, 449 insertions(+), 308 deletions(-) create mode 100644 scripts/qq_browser_login.py create mode 100644 scripts/qq_verify_entitlement.py diff --git a/package-lock.json b/package-lock.json index adbc059..dc0b5f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@discordjs/opus": "^0.10.0", "@honeybbq/teamspeak-client": "^0.2.1", "@koa/router": "^15.4.0", - "@sansenjian/qq-music-api": "^2.2.9", + "@sansenjian/qq-music-api": "file:../qq-music-api", "axios": "^1.14.0", "better-sqlite3": "^12.8.0", "chalk": "^5.6.2", @@ -38,6 +38,62 @@ "vitest": "^4.1.2" } }, + "../qq-music-api": { + "name": "@sansenjian/qq-music-api", + "version": "2.2.11", + "license": "MIT", + "dependencies": { + "@koa/router": "^15.3.1", + "axios": "^1.13.6", + "date-fns": "^4.1.0", + "is-generator-function": "1.0.10", + "koa": "^2.16.1", + "koa-bodyparser": "^4.4.1", + "koa-static": "^5.0.0", + "reflect-metadata": "^0.2.2" + }, + "bin": { + "qq-music-api": "dist/app.js" + }, + "devDependencies": { + "@commitlint/cli": "^18.0.0", + "@commitlint/config-conventional": "^18.0.0", + "@types/jest": "^30.0.0", + "@types/koa": "^3.0.1", + "@types/koa__router": "^12.0.5", + "@types/koa-bodyparser": "^4.3.13", + "@types/koa-static": "^4.0.4", + "@types/node": "^25.3.3", + "@types/supertest": "^7.2.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "chalk": "^4.1.0", + "conventional-changelog-cli": "^4.0.0", + "eslint": "^8.57.0", + "eslint-config-standard": "^17.0.0", + "eslint-config-standard-with-typescript": "^43.0.1", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-n": "^16.0.0", + "eslint-plugin-promise": "^6.0.0", + "husky": "^9.0.0", + "jest": "^30.2.0", + "lint-staged": "^15.0.0", + "nodemon": "^3.1.14", + "prettier": "^3.8.1", + "rimraf": "^6.1.3", + "sinon": "^21.0.1", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "tsx": "^4.21.0", + "typescript": "5.7.3", + "vitepress": "^1.6.4", + "vue": "^3.5.29" + }, + "engines": { + "node": "22.x" + } + }, "node_modules/@derhuerst/http-basic": { "version": "8.2.4", "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", @@ -939,189 +995,8 @@ "license": "MIT" }, "node_modules/@sansenjian/qq-music-api": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@sansenjian/qq-music-api/-/qq-music-api-2.2.9.tgz", - "integrity": "sha512-nlXfuShYWuRjcMB6tXMlVyBW6dRuYwNJNYH8AtSkP8qDf0gxViHDXNkao22IVVOoSQl7risYXbOHPsPqrn4WQQ==", - "license": "MIT", - "dependencies": { - "@koa/router": "^15.3.1", - "axios": "^1.13.6", - "date-fns": "^4.1.0", - "is-generator-function": "1.0.10", - "koa": "^2.16.1", - "koa-bodyparser": "^4.4.1", - "koa-static": "^5.0.0", - "reflect-metadata": "^0.2.2" - }, - "bin": { - "qq-music-api": "dist/app.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/http-errors/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/koa": { - "version": "2.16.4", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", - "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", - "license": "MIT", - "dependencies": { - "accepts": "^1.3.5", - "cache-content-type": "^1.0.0", - "content-disposition": "~0.5.2", - "content-type": "^1.0.4", - "cookies": "~0.9.0", - "debug": "^4.3.2", - "delegates": "^1.0.0", - "depd": "^2.0.0", - "destroy": "^1.0.4", - "encodeurl": "^1.0.2", - "escape-html": "^1.0.3", - "fresh": "~0.5.2", - "http-assert": "^1.3.0", - "http-errors": "^1.6.3", - "is-generator-function": "^1.0.7", - "koa-compose": "^4.1.0", - "koa-convert": "^2.0.0", - "on-finished": "^2.3.0", - "only": "~0.0.2", - "parseurl": "^1.3.2", - "statuses": "^1.5.0", - "type-is": "^1.6.16", - "vary": "^1.1.2" - }, - "engines": { - "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sansenjian/qq-music-api/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } + "resolved": "../qq-music-api", + "link": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1745,40 +1620,6 @@ "node": ">= 0.8" } }, - "node_modules/cache-content-type": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", - "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", - "license": "MIT", - "dependencies": { - "mime-types": "^2.1.18", - "ylru": "^1.2.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/cache-content-type/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cache-content-type/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1874,16 +1715,6 @@ "node": ">=12" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, "node_modules/co-body": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.2.0.tgz", @@ -2140,16 +1971,6 @@ "node": ">= 14" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3216,21 +3037,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3341,19 +3147,6 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", "license": "MIT" }, - "node_modules/koa-convert": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", - "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", - "license": "MIT", - "dependencies": { - "co": "^4.6.0", - "koa-compose": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/koa-send": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", @@ -4491,11 +4284,6 @@ "wrappy": "1" } }, - "node_modules/only": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", - "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -5058,12 +4846,6 @@ "node": ">= 12.13.0" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6261,15 +6043,6 @@ "node": ">=12" } }, - "node_modules/ylru": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", - "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/yt-dlp-wrap": { "version": "2.3.12", "resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz", diff --git a/package.json b/package.json index 164e7ef..c027b42 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@discordjs/opus": "^0.10.0", "@honeybbq/teamspeak-client": "^0.2.1", "@koa/router": "^15.4.0", - "@sansenjian/qq-music-api": "^2.2.9", + "@sansenjian/qq-music-api": "file:../qq-music-api", "axios": "^1.14.0", "better-sqlite3": "^12.8.0", "chalk": "^5.6.2", diff --git a/scripts/qq_browser_login.py b/scripts/qq_browser_login.py new file mode 100644 index 0000000..bede42c --- /dev/null +++ b/scripts/qq_browser_login.py @@ -0,0 +1,212 @@ +"""Open a real Chromium browser at y.qq.com, let the user log in via any +method (password / QR / QQ connect), then extract the resulting cookie +set and save it to data/cookies/qq.json. Also tests whether the cookie +actually unlocks a known VIP track (Jay Chou 稻香) against the local +QQ Music API before declaring success. + +Usage: + "C:/Users/saopig1/miniforge3/python.exe" scripts/qq_browser_login.py + +Steps: + 1. A visible Chromium window opens at y.qq.com/n/ryqq/player + 2. Click the login button in the top right and log in with your + real QQ Music account (the one that has VIP) + 3. The script POLLS cookies in the background and auto-detects + successful login by watching for the `uin` cookie to appear + 4. Once detected, cookies are captured, tested against + /getMusicPlay for 稻香, and saved on success + 5. If VIP still fails, cookies are NOT saved — your existing bot + cookie stays untouched + +No terminal input required — the script exits on its own when login +is detected (or after the configured timeout). +""" +from __future__ import annotations + +import json +import re +import time +from pathlib import Path + +import requests +from playwright.sync_api import sync_playwright + +BOT_ROOT = Path(r"C:\Users\saopig1\Music\teamspeak music bot") +COOKIE_FILE = BOT_ROOT / "data" / "cookies" / "qq.json" +QQ_API = "http://localhost:3200" +VIP_TEST_SONGMID = "003aAYrm3GE0Ac" # 稻香 周杰伦 + +# How long to wait for the user to finish logging in +LOGIN_TIMEOUT_S = 300 # 5 minutes +POLL_INTERVAL_S = 1.0 +# After detecting login, wait a bit for extra cookies (e.g. qqmusic_key) +SETTLE_DELAY_S = 4.0 + + +def qq_cookies(ctx) -> list[dict]: + wanted_suffixes = (".qq.com", "y.qq.com", ".music.qq.com") + return [ + c for c in ctx.cookies() + if any(c.get("domain", "").endswith(s) or c.get("domain", "") == s.lstrip(".") + for s in wanted_suffixes) + ] + + +def cookies_to_header(cookies: list[dict]) -> str: + return "; ".join(f"{c['name']}={c['value']}" for c in cookies) + + +def cookie_has_uin(cookies: list[dict]) -> str | None: + for c in cookies: + if c["name"] == "uin" and c["value"]: + return c["value"] + return None + + +def test_vip_unlock(cookie_header: str) -> tuple[bool, dict]: + try: + r = requests.get( + f"{QQ_API}/getMusicPlay", + params={"songmid": VIP_TEST_SONGMID, "cookie": cookie_header}, + timeout=10, + proxies={"http": None, "https": None}, + ) + body = r.json() + play = body.get("data", {}).get("playUrl", {}).get(VIP_TEST_SONGMID, {}) + url = play.get("url", "") + return ( + bool(url), + { + "url_length": len(url), + "url_prefix": url[:120] if url else "", + "error": play.get("error", ""), + }, + ) + except Exception as e: + return False, {"error": f"request failed: {e}"} + + +def main() -> int: + print("[setup] launching visible Chromium — look for the window on your desktop") + print("[setup] goto https://y.qq.com/n/ryqq/player") + print() + print("action required:") + print(" 1. Click the 登录 button (top-right) in the browser window") + print(" 2. Log in with your VIP QQ Music account (QR / password / WeChat)") + print(" 3. Do NOTHING in this terminal — the script detects login itself") + print() + + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + ctx = browser.new_context( + viewport={"width": 1280, "height": 820}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/132.0.0.0 Safari/537.36" + ), + ) + page = ctx.new_page() + try: + page.goto("https://y.qq.com/n/ryqq/player", wait_until="domcontentloaded", timeout=30_000) + except Exception as e: + print(f"[warn] initial navigation slow: {e}") + + print(f"[wait] polling every {POLL_INTERVAL_S}s for login (timeout {LOGIN_TIMEOUT_S}s)") + deadline = time.time() + LOGIN_TIMEOUT_S + uin_detected: str | None = None + last_report = 0.0 + while time.time() < deadline: + cks = qq_cookies(ctx) + uin = cookie_has_uin(cks) + if uin: + uin_detected = uin + print(f"[detect] uin cookie appeared: {uin}") + break + now = time.time() + if now - last_report >= 15: + remaining = int(deadline - now) + n = len(cks) + print(f"[wait] still waiting... {n} qq.com cookies so far, {remaining}s left") + last_report = now + time.sleep(POLL_INTERVAL_S) + + if not uin_detected: + print("[abort] login not detected within timeout") + browser.close() + return 1 + + print(f"[settle] waiting {SETTLE_DELAY_S}s for session cookies to populate") + time.sleep(SETTLE_DELAY_S) + + cks = qq_cookies(ctx) + cookie_header = cookies_to_header(cks) + print(f"[capture] {len(cks)} cookies, {len(cookie_header)} char header") + + qm_key = next((c["value"] for c in cks if c["name"] == "qqmusic_key"), "") + qm_keyst = next((c["value"] for c in cks if c["name"] == "qm_keyst"), "") + p_skey = next((c["value"] for c in cks if c["name"] == "p_skey"), "") + print(f"[capture] qqmusic_key: {'present (' + qm_key[:20] + '...)' if qm_key else '(absent)'}") + print(f"[capture] qm_keyst : {'present (' + qm_keyst[:20] + '...)' if qm_keyst else '(absent)'}") + print(f"[capture] p_skey : {'present' if p_skey else '(absent)'}") + + print("\n[test] calling /getMusicPlay for 稻香 with captured cookie...") + unlocked, details = test_vip_unlock(cookie_header) + print(f"[test] unlocked: {unlocked}") + print(f"[test] details: {details}") + + if not unlocked: + print( + "\n[result] VIP did NOT unlock even with browser-extracted cookies.\n" + " Existing cookie file is UNTOUCHED.\n" + " Diagnosis: the login flow is not the bottleneck — the\n" + " account likely lacks entitlement for this specific track,\n" + " OR QQ requires additional session setup (gateway handshake)\n" + " beyond what's in the cookie itself.\n" + ) + # Dump the full cookie set for inspection + dump_path = BOT_ROOT / "data" / "cookies" / "qq.browser-capture.json" + dump_path.write_text( + json.dumps( + {"cookie": cookie_header, "cookieList": cks, "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"[dump] full browser cookies written to {dump_path}") + print(" (for side-by-side comparison with OAuth-derived cookies)") + browser.close() + return 2 + + print("\n[save] VIP unlocked. Writing cookie to bot...") + COOKIE_FILE.write_text( + json.dumps( + {"cookie": cookie_header, "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")}, + ensure_ascii=False, + ), + encoding="utf-8", + ) + print(f"[save] wrote {COOKIE_FILE}") + + try: + r = requests.post( + "http://localhost:3000/api/auth/cookie", + json={"platform": "qq", "cookie": cookie_header}, + timeout=5, + proxies={"http": None, "https": None}, + ) + print(f"[notify] /api/auth/cookie POST: {r.status_code}") + except Exception as e: + print(f"[notify] failed to push cookie to bot: {e}") + print(" Restart the bot to pick up the new cookie from disk.") + + print("\n[done] VIP should now work through the bot. Try playing 稻香!") + browser.close() + return 0 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/scripts/qq_verify_entitlement.py b/scripts/qq_verify_entitlement.py new file mode 100644 index 0000000..a70e489 --- /dev/null +++ b/scripts/qq_verify_entitlement.py @@ -0,0 +1,119 @@ +"""Open a visible browser at y.qq.com so the user can manually verify +whether their VIP account can play 稻香 (Jay Chou) in the real QQ Music +web player. + +If the browser plays the song → entitlement exists and our 104003 is a +request-signing issue. +If the browser refuses / shows a VIP modal / silently fails → the +account doesn't have entitlement OR QQ's web player hits the same wall. +""" +import sys +import time +from playwright.sync_api import sync_playwright + +# Force line-buffered stdout so logs actually reach the output file +sys.stdout.reconfigure(line_buffering=True) + +START_URL = "https://y.qq.com/n/ryqq/player" +SONG_URL = "https://y.qq.com/n/ryqq/songDetail/003aAYrm3GE0Ac" + + +def log(msg: str) -> None: + print(msg, flush=True) + + +def main() -> int: + log("[setup] launching visible Chromium") + log(f"[setup] start URL: {START_URL}") + log(f"[setup] song URL: {SONG_URL}") + log("") + log("action required:") + log(" 1. The browser opens at the player page") + log(" 2. Make sure your VIP account is logged in (top-right avatar)") + log(" — if not, log in now, the script will wait") + log(" 3. Once logged in, the browser will auto-navigate to 稻香") + log(" 4. Click the PLAY button and report what happens:") + log(" (a) song plays → account has entitlement, issue is request signing") + log(" (b) VIP modal → account needs higher tier / digital album purchase") + log(" (c) silent failure → QQ web player has same 104003 wall") + log("") + log("[wait] browser stays open for 5 minutes") + log("") + + with sync_playwright() as p: + try: + browser = p.chromium.launch(headless=False) + except Exception as e: + log(f"[fatal] failed to launch Chromium: {e}") + return 1 + + ctx = browser.new_context( + viewport={"width": 1400, "height": 900}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/132.0.0.0 Safari/537.36" + ), + ) + page = ctx.new_page() + + # Log every navigation so we can see if pages fail + page.on("framenavigated", lambda f: log(f"[nav] {f.url[:120]}") if f == page.main_frame else None) + page.on("pageerror", lambda e: log(f"[js-error] {str(e)[:200]}")) + + # Step 1: open the player page (known working) + log(f"[goto] {START_URL}") + try: + page.goto(START_URL, wait_until="domcontentloaded", timeout=30_000) + log(f"[ok] loaded: {page.url}") + except Exception as e: + log(f"[warn] initial goto failed: {e}") + log("[warn] browser stays open, try manual navigation") + + # Wait briefly for auth state to settle + time.sleep(3) + + # Check if the user is logged in via the uin cookie + cookies = ctx.cookies() + uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None) + if uin: + log(f"[auth] logged in as uin={uin}") + else: + log("[auth] not logged in yet — please log in via the top-right avatar") + log("[auth] waiting up to 2 minutes for login...") + end = time.time() + 120 + while time.time() < end: + time.sleep(1) + cookies = ctx.cookies() + uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None) + if uin: + log(f"[auth] detected login: uin={uin}") + break + if not uin: + log("[abort] no login detected within 2 minutes") + time.sleep(30) # keep browser visible + browser.close() + return 2 + + # Step 2: navigate to the song page + log(f"[goto] {SONG_URL}") + try: + page.goto(SONG_URL, wait_until="domcontentloaded", timeout=30_000) + log(f"[ok] loaded: {page.url}") + except Exception as e: + log(f"[warn] song navigation failed: {e}") + log(f"[info] current page: {page.url}") + + log("") + log("===========================================================") + log("browser is open on the song page — click PLAY and observe.") + log("keeping browser open for 5 more minutes") + log("===========================================================") + + time.sleep(300) + browser.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/music/api-server.ts b/src/music/api-server.ts index 05fe3fc..4ed45b7 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -32,6 +32,7 @@ export function createApiServerManager( 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}`; @@ -62,7 +63,10 @@ export function createApiServerManager( logger.error({ err }, "Failed to start NetEase Cloud Music API"); } - // Start QQ Music API (auto-starts on import) + // 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) { @@ -71,11 +75,22 @@ export function createApiServerManager( "QQ Music API port already in use — reusing existing instance" ); } else { - await import("@sansenjian/qq-music-api"); - logger.info( - { port: options.qqMusicPort }, - "QQ Music API started" - ); + const qqModule = (await import("@sansenjian/qq-music-api")) as any; + const koaApp = qqModule.default ?? qqModule; + 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) { logger.warn( @@ -91,6 +106,10 @@ export function createApiServerManager( (neteaseServer as any).close(); } neteaseServer = null; + if (qqMusicServer && typeof (qqMusicServer as any).close === "function") { + (qqMusicServer as any).close(); + } + qqMusicServer = null; }, getNeteaseBaseUrl(): string { diff --git a/src/music/qq.ts b/src/music/qq.ts index 064fff7..f91345d 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -66,7 +66,12 @@ export class QQMusicProvider implements MusicProvider { } async getSongDetail(songId: string): Promise { - // getSongInfo requires cookie; use search as fallback + // Try /getSongInfo for full metadata, but fall through to a minimal + // stub if the library endpoint fails (current @sansenjian/qq-music-api + // returns upstream code 500001 for this route — the param format it + // sends doesn't match QQ's current API). The bot's resolveAndPlay path + // only needs `id` and `platform` to fetch a play URL, and the fallback + // stub is sufficient to let /play-by-id and /add-by-id flows succeed. try { const res = await this.api.get("/getSongInfo", { params: { songmid: songId, ...this.cookieParams }, @@ -87,9 +92,20 @@ export class QQMusicProvider implements MusicProvider { }; } } catch { - // fallback: search by songmid (less reliable) + // fall through to stub } - return null; + // Minimal stub — resolveAndPlay only needs id + platform to fetch a + // play URL. Name/artist/album will be empty in play history, but the + // song will actually play, which is the important part. + return { + id: songId, + name: "", + artist: "", + album: "", + duration: 0, + coverUrl: "", + platform: "qq", + }; } async getPlaylistSongs(playlistId: string): Promise { @@ -215,23 +231,25 @@ export class QQMusicProvider implements MusicProvider { // the main router; the real endpoint is /user/getUserAvatar, and even // that just builds a static URL from a uin without validating the // cookie against QQ. Round-trip through /user/getUserPlaylists which - // actually hits QQ Music with the cookie; if we get playlists back, - // the cookie is valid. Derive nickname/avatar from the uin parsed out - // of the cookie. + // actually hits QQ Music with the cookie; if the upstream returns + // code=0, the cookie is valid. + // + // IMPORTANT: /user/getUserPlaylists requires `uin` as a query param — + // the library 400s with "缺少 uin 参数" otherwise. Parse it out of the + // cookie (uin=; comes after the various *uin prefixed names, which + // is why the regex anchors on a word boundary). + const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie); + const uin = uinMatch ? uinMatch[1] : ""; + if (!uin) return { loggedIn: false }; try { - const uinMatch = /\buin=o?0?(\d+)/.exec(this.cookie); - const uin = uinMatch ? uinMatch[1] : ""; const res = await this.api.get("/user/getUserPlaylists", { - params: { ...this.cookieParams }, + params: { uin, ...this.cookieParams }, }); - const ok = res.data?.response?.data || res.data?.data; - if (!ok) return { loggedIn: false }; + if (res.data?.response?.code !== 0) return { loggedIn: false }; return { loggedIn: true, - nickname: uin ? `QQ ${uin}` : "QQ Music", - avatarUrl: uin - ? `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100` - : undefined, + nickname: `QQ ${uin}`, + avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100`, }; } catch { return { loggedIn: false };