mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52: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
+25
-6
@@ -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<Server>((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 {
|
||||
|
||||
+33
-15
@@ -66,7 +66,12 @@ export class QQMusicProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
// 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<Song[]> {
|
||||
@@ -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=<qq>; 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 };
|
||||
|
||||
Reference in new issue
Block a user