mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat(qq): consume local @sansenjian/qq-music-api fork with VIP-aware getMusicPlay
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
3aa06006fe
commit
5647cf6d36
6 files changed
+449
-308
No files matched your search
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user