fix(qq): apply musicu.fcg upstream fixes + add playlist search

Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
NoSetViolinandClaude Opus 4.7 committed 2026-05-14 16:28:12 +08:00
1 parent 0e68e287b7
commit 1d2da33d3c
1 file changed
+102 -82
+102 -82
View File
@@ -12,20 +12,21 @@ import type {
} from "./provider.js"; } from "./provider.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
// Primary search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic // Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// endpoint, currently working without a cookie). // batch). Was broken ca. 2026-05 due to two upstream API changes:
const qqSearchApi = axios.create({ // 1. searchid param must NOT be present (causes all lists to be empty)
baseURL: "https://c.y.qq.com", // 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000, timeout: 10000,
headers: { referer: "https://y.qq.com" }, headers: { referer: "https://y.qq.com" },
}); });
// Fallback search client: u.y.qq.com/cgi-bin/musicu.fcg used JSON sub-requests // Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// but started returning is_filter=-9 (empty results) for unauthenticated // endpoint, song + album only, no playlist support).
// requests ca. 2026-05. Kept as a redundancy in case the primary endpoint const qqSearchApi = axios.create({
// goes down or changes its response format. baseURL: "https://c.y.qq.com",
const qqFallbackApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000, timeout: 10000,
headers: { referer: "https://y.qq.com" }, headers: { referer: "https://y.qq.com" },
}); });
@@ -92,113 +93,132 @@ export class QQMusicProvider implements MusicProvider {
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20): Promise<SearchResult> {
// Primary: c.y.qq.com client_search_cp (currently working). // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
const primary = await this.searchViaClientSearchCp(query, limit); // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary; if (primary) return primary;
// Fallback: u.y.qq.com musicu.fcg JSON sub-requests (may return empty // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// when Tencent applies is_filter, but acts as a safety net in case the // no playlist support). Kept as redundancy.
// primary endpoint format changes or goes down). return this.searchViaClientSearchCp(query, limit);
return this.searchViaMusicuFcg(query, limit);
} }
/** Primary search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
private async searchViaClientSearchCp( *
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string, query: string,
limit: number limit: number
): Promise<SearchResult | null> { ): Promise<SearchResult | null> {
try { try {
const songParams = { const numPerPage = Math.max(10, Math.min(limit, 50));
w: query, const reqData = JSON.stringify({
format: "json", req_0: {
p: 1, module: "music.search.SearchCgiService",
n: Math.min(limit, 50), method: "DoSearchForQQMusicDesktop",
type: 0, param: { query, num_per_page: numPerPage, search_type: 0 },
cr: 1, },
}; req_album: {
const albumParams = { module: "music.search.SearchCgiService",
w: query, method: "DoSearchForQQMusicDesktop",
format: "json", param: { query, num_per_page: 10, search_type: 2 },
p: 1, },
n: 5, req_playlist: {
t: 8, module: "music.search.SearchCgiService",
cr: 1, method: "DoSearchForQQMusicDesktop",
}; param: { query, num_per_page: 10, search_type: 3 },
},
const [songRes, albumRes] = await Promise.allSettled([ });
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }), const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }), params: { format: "json", data: reqData },
]); });
const songList: any[] = const songList: any[] =
songRes.status === "fulfilled" res.data?.req_0?.data?.body?.song?.list ?? [];
? (songRes.value.data?.data?.song?.list ?? [])
: [];
// Treat zero songs as "endpoint returned no useful data" so we fall
// through to the fallback path.
if (songList.length === 0) return null; if (songList.length === 0) return null;
const songs: Song[] = songList.map((s: any) => ({ const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""), id: String(s.mid ?? s.id),
name: s.songname ?? s.name ?? "", name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? s.album?.name ?? "", album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0, duration: s.interval ?? 0,
coverUrl: s.albummid coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "", : "",
platform: "qq", platform: "qq",
})); }));
const albumList: any[] = const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList); const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums }; const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch { } catch {
return null; // network error or unexpected response → fall back return null;
} }
} }
/** Fallback search via u.y.qq.com/cgi-bin/musicu.fcg */ /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaMusicuFcg( private async searchViaClientSearchCp(
query: string, query: string,
limit: number limit: number
): Promise<SearchResult> { ): Promise<SearchResult> {
const reqData = JSON.stringify({ const songParams = {
req_0: { w: query,
module: "music.search.SearchCgiService", format: "json",
method: "DoSearchForQQMusicDesktop", p: 1,
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, n: Math.min(limit, 50),
}, type: 0,
req_album: { cr: 1,
module: "music.search.SearchCgiService", };
method: "DoSearchForQQMusicDesktop", const albumParams = {
param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, w: query,
}, format: "json",
}); p: 1,
const res = await qqFallbackApi.get("/cgi-bin/musicu.fcg", { n: 5,
params: { format: "json", data: reqData }, t: 8,
}); cr: 1,
const list: any[] = };
res.data?.req_0?.data?.body?.song?.list ?? [];
const songs: Song[] = list.map((s: any) => ({ const [songRes, albumRes] = await Promise.allSettled([
id: String(s.mid ?? s.id), qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
name: s.title ?? s.name ?? "", qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "", album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0, duration: s.interval ?? 0,
coverUrl: s.album?.mid coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "", : "",
platform: "qq", platform: "qq",
})); }));
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList); const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums }; return { songs, playlists: [], albums };