fix(qq): correct user-playlists field mapping; add collected playlists and daily recommend

Three fixes to the QQ Music provider, all surfaced by enabling the QQ
tab on Home/Library:

1. getUserPlaylists field names were wrong. The upstream
   fcg_get_profile_homepage actually returns title/picurl/subtitle,
   not dissname/imgurl/song_count, so all playlist cards rendered as
   empty placeholders. Map title->name, picurl->coverUrl, and parse
   '(\d+)首' from subtitle for songCount.

2. getUserPlaylists only returned playlists the user CREATED. Now also
   fetches COLLECTED playlists from c.y.qq.com fcg_get_profile_order_asset
   (reqtype=3, requires g_tk derived from p_skey cookie) and concatenates
   them after the created list — same order as the QQ desktop app.

3. Add getDailyRecommendSongs implementation backed by /getNewSongs
   (新歌速递, ~20 newest songs). The QQ provider previously didn't
   implement this method, so the daily-recommend QQ tab was empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-06 13:56:54 +08:00
1 parent ec02887d22
commit e8d2f1ad98
1 file changed
+103 -13
+103 -13
View File
@@ -18,6 +18,22 @@ const qqDirectApi = axios.create({
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com/" },
});
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
hash = (hash + (hash << 5) + pSkey.charCodeAt(i)) | 0;
}
return hash & 0x7fffffff;
}
export class QQMusicProvider implements MusicProvider {
readonly platform = "qq" as const;
private api: AxiosInstance;
@@ -291,25 +307,99 @@ export class QQMusicProvider implements MusicProvider {
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return [];
async getDailyRecommendSongs(): Promise<Song[]> {
// QQ has no per-user daily list; use newsong.NewSongServer (新歌速递)
// as the closest analogue. Returns ~20 newly-released songs.
try {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
const res = await this.api.get("/getNewSongs", {
params: { ...this.cookieParams },
});
if (res.data?.response?.code !== 0) return [];
return (res.data?.response?.data?.playlists ?? []).map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.name ?? "",
coverUrl: p.imgurl ?? p.coverUrl ?? "",
songCount: p.song_count ?? p.listennum ?? 0,
const list: any[] = res.data?.response?.new_song?.data?.songlist ?? [];
return list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
} catch {
return [];
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return [];
// Created and collected playlists come from two separate QQ endpoints.
// Run them in parallel and concatenate (created first, then collected),
// matching the order shown in the QQ Music desktop app.
const [created, collected] = await Promise.all([
this.fetchCreatedPlaylists(uin),
this.fetchCollectedPlaylists(uin),
]);
return [...created, ...collected];
}
private async fetchCreatedPlaylists(uin: string): Promise<Playlist[]> {
try {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
if (res.data?.response?.code !== 0) return [];
return (res.data?.response?.data?.playlists ?? []).map((p: any) => {
// fcg_get_profile_homepage returns title/picurl/subtitle ("X首 Y次播放").
const subtitle: string = p.subtitle ?? "";
const songCountFromSubtitle = parseInt(subtitle.match(/(\d+)\s*首/)?.[1] ?? "0", 10);
return {
id: String(p.dissid ?? p.id ?? ""),
name: p.title ?? p.dissname ?? p.name ?? "",
coverUrl: p.picurl ?? p.imgurl ?? p.coverUrl ?? "",
songCount: p.song_count ?? p.listennum ?? songCountFromSubtitle,
platform: "qq",
};
});
} catch {
return [];
}
}
private async fetchCollectedPlaylists(uin: string): Promise<Playlist[]> {
// c.y.qq.com fav endpoint: reqtype=3 returns collected playlists (cdlist).
// Requires g_tk derived from the p_skey cookie.
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
if (!pSkeyMatch) return [];
const gtk = computeGtk(pSkeyMatch[1]);
try {
const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", {
params: {
ct: 20,
cid: 205360956,
userid: uin,
reqtype: 3,
sin: 0,
ein: 29,
g_tk: gtk,
format: "json",
},
headers: { Cookie: this.cookie },
});
if (res.data?.code !== 0) return [];
return (res.data?.data?.cdlist ?? []).map((p: any) => ({
id: String(p.dissid ?? ""),
name: p.dissname ?? "",
coverUrl: p.logo ?? "",
songCount: p.songnum ?? 0,
platform: "qq" as const,
}));
} catch {
return [];
}
}
}