mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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>
213 lines
8.1 KiB
Python
213 lines
8.1 KiB
Python
"""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())
|