From 5647cf6d361d56e7cbf4be17d366471a98435c4c Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 22:52:12 +0800 Subject: [PATCH] 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 };