From 0e68e287b7e548193ea8835bae7a90a1b7061bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=A2=93=E5=96=B5=5F=E3=81=82=E3=81=9A=E3=81=AB?= =?UTF-8?q?=E3=82=83=E3=82=93?= Date: Thu, 14 May 2026 15:55:21 +0800 Subject: [PATCH 1/5] fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results) for unauthenticated requests. Primary search now uses the classic c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg path kept as a fallback in case the primary endpoint changes format or goes down. Co-Authored-By: Claude Opus 4.7 --- src/music/qq.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/src/music/qq.ts b/src/music/qq.ts index 1e7a8ae..aed3994 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -12,9 +12,19 @@ import type { } from "./provider.js"; import { parseLyrics } from "./netease.js"; -// Direct QQ Music API client — bypasses the local API server for search -// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint. -const qqDirectApi = axios.create({ +// Primary search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic +// endpoint, currently working without a cookie). +const qqSearchApi = axios.create({ + baseURL: "https://c.y.qq.com", + timeout: 10000, + headers: { referer: "https://y.qq.com" }, +}); + +// Fallback search client: u.y.qq.com/cgi-bin/musicu.fcg used JSON sub-requests +// but started returning is_filter=-9 (empty results) for unauthenticated +// requests ca. 2026-05. Kept as a redundancy in case the primary endpoint +// goes down or changes its response format. +const qqFallbackApi = axios.create({ baseURL: "https://u.y.qq.com", timeout: 10000, headers: { referer: "https://y.qq.com" }, @@ -82,6 +92,82 @@ export class QQMusicProvider implements MusicProvider { } async search(query: string, limit = 20): Promise { + // Primary: c.y.qq.com client_search_cp (currently working). + const primary = await this.searchViaClientSearchCp(query, limit); + if (primary) return primary; + + // Fallback: u.y.qq.com musicu.fcg JSON sub-requests (may return empty + // when Tencent applies is_filter, but acts as a safety net in case the + // primary endpoint format changes or goes down). + return this.searchViaMusicuFcg(query, limit); + } + + /** Primary search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ + private async searchViaClientSearchCp( + query: string, + limit: number + ): Promise { + try { + const songParams = { + w: query, + format: "json", + p: 1, + n: Math.min(limit, 50), + type: 0, + cr: 1, + }; + const albumParams = { + w: query, + format: "json", + p: 1, + n: 5, + t: 8, + cr: 1, + }; + + const [songRes, albumRes] = await Promise.allSettled([ + qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }), + qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }), + ]); + + const songList: any[] = + songRes.status === "fulfilled" + ? (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; + + 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(" / "), + album: s.albumname ?? s.album?.name ?? "", + duration: s.interval ?? 0, + coverUrl: s.albummid + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` + : "", + platform: "qq", + })); + + const albumList: any[] = + albumRes.status === "fulfilled" + ? (albumRes.value.data?.data?.album?.list ?? []) + : []; + const albums = mapQqAlbums(albumList); + + return { songs, playlists: [], albums }; + } catch { + return null; // network error or unexpected response → fall back + } + } + + /** Fallback search via u.y.qq.com/cgi-bin/musicu.fcg */ + private async searchViaMusicuFcg( + query: string, + limit: number + ): Promise { const reqData = JSON.stringify({ req_0: { module: "music.search.SearchCgiService", @@ -94,7 +180,7 @@ export class QQMusicProvider implements MusicProvider { param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, }, }); - const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", { + const res = await qqFallbackApi.get("/cgi-bin/musicu.fcg", { params: { format: "json", data: reqData }, }); const list: any[] = From 1d2da33d3ca19c30eb1d594e300fe016b8e0c704 Mon Sep 17 00:00:00 2001 From: NoSetViolin Date: Thu, 14 May 2026 16:28:12 +0800 Subject: [PATCH 2/5] 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 --- src/music/qq.ts | 184 +++++++++++++++++++++++++++--------------------- 1 file changed, 102 insertions(+), 82 deletions(-) diff --git a/src/music/qq.ts b/src/music/qq.ts index aed3994..30dddec 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -12,20 +12,21 @@ import type { } from "./provider.js"; import { parseLyrics } from "./netease.js"; -// Primary search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic -// endpoint, currently working without a cookie). -const qqSearchApi = axios.create({ - baseURL: "https://c.y.qq.com", +// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request +// batch). Was broken ca. 2026-05 due to two upstream API changes: +// 1. searchid param must NOT be present (causes all lists to be empty) +// 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, headers: { referer: "https://y.qq.com" }, }); -// Fallback search client: u.y.qq.com/cgi-bin/musicu.fcg used JSON sub-requests -// but started returning is_filter=-9 (empty results) for unauthenticated -// requests ca. 2026-05. Kept as a redundancy in case the primary endpoint -// goes down or changes its response format. -const qqFallbackApi = axios.create({ - baseURL: "https://u.y.qq.com", +// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic +// endpoint, song + album only, no playlist support). +const qqSearchApi = axios.create({ + baseURL: "https://c.y.qq.com", timeout: 10000, headers: { referer: "https://y.qq.com" }, }); @@ -92,113 +93,132 @@ export class QQMusicProvider implements MusicProvider { } async search(query: string, limit = 20): Promise { - // Primary: c.y.qq.com client_search_cp (currently working). - const primary = await this.searchViaClientSearchCp(query, limit); + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + + // 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; - // Fallback: u.y.qq.com musicu.fcg JSON sub-requests (may return empty - // when Tencent applies is_filter, but acts as a safety net in case the - // primary endpoint format changes or goes down). - return this.searchViaMusicuFcg(query, limit); + // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album, + // no playlist support). Kept as redundancy. + return this.searchViaClientSearchCp(query, limit); } - /** Primary search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ - private async searchViaClientSearchCp( + /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg. + * + * 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, limit: number ): Promise { try { - const songParams = { - w: query, - format: "json", - p: 1, - n: Math.min(limit, 50), - type: 0, - cr: 1, - }; - const albumParams = { - w: query, - format: "json", - p: 1, - n: 5, - t: 8, - cr: 1, - }; - - const [songRes, albumRes] = await Promise.allSettled([ - qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }), - qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }), - ]); + const numPerPage = Math.max(10, Math.min(limit, 50)); + const reqData = JSON.stringify({ + req_0: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { query, num_per_page: numPerPage, search_type: 0 }, + }, + req_album: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { query, num_per_page: 10, search_type: 2 }, + }, + req_playlist: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { query, num_per_page: 10, search_type: 3 }, + }, + }); + const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { + params: { format: "json", data: reqData }, + }); const songList: any[] = - songRes.status === "fulfilled" - ? (songRes.value.data?.data?.song?.list ?? []) - : []; - - // Treat zero songs as "endpoint returned no useful data" so we fall - // through to the fallback path. + res.data?.req_0?.data?.body?.song?.list ?? []; if (songList.length === 0) return null; const songs: Song[] = songList.map((s: any) => ({ - id: String(s.songmid ?? s.songid ?? ""), - name: s.songname ?? s.name ?? "", + id: String(s.mid ?? s.id), + name: s.title ?? s.name ?? "", 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, - coverUrl: s.albummid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` + coverUrl: s.album?.mid + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` : "", platform: "qq", })); - const albumList: any[] = - albumRes.status === "fulfilled" - ? (albumRes.value.data?.data?.album?.list ?? []) - : []; + const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; 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 { - return null; // network error or unexpected response → fall back + return null; } } - /** Fallback search via u.y.qq.com/cgi-bin/musicu.fcg */ - private async searchViaMusicuFcg( + /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ + private async searchViaClientSearchCp( query: string, limit: number ): Promise { - const reqData = JSON.stringify({ - req_0: { - module: "music.search.SearchCgiService", - method: "DoSearchForQQMusicDesktop", - param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, - }, - req_album: { - module: "music.search.SearchCgiService", - method: "DoSearchForQQMusicDesktop", - param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, - }, - }); - const res = await qqFallbackApi.get("/cgi-bin/musicu.fcg", { - params: { format: "json", data: reqData }, - }); - const list: any[] = - res.data?.req_0?.data?.body?.song?.list ?? []; + const songParams = { + w: query, + format: "json", + p: 1, + n: Math.min(limit, 50), + type: 0, + cr: 1, + }; + const albumParams = { + w: query, + format: "json", + p: 1, + n: 5, + t: 8, + cr: 1, + }; - const songs: Song[] = list.map((s: any) => ({ - id: String(s.mid ?? s.id), - name: s.title ?? s.name ?? "", + const [songRes, albumRes] = await Promise.allSettled([ + qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }), + 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(" / "), - album: s.album?.name ?? s.album?.title ?? "", + album: s.albumname ?? s.album?.name ?? "", duration: s.interval ?? 0, - coverUrl: s.album?.mid - ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` + coverUrl: s.albummid + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` : "", 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); return { songs, playlists: [], albums }; From ea6501ea81d2e7e0afb43a1c1c3077b6690f483d Mon Sep 17 00:00:00 2001 From: NoSetViolin Date: Thu, 14 May 2026 16:34:26 +0800 Subject: [PATCH 3/5] feat(web): add platform badges to album and playlist cards in search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Album and playlist results now show source tags (网易云, QQ, B站, YouTube) matching the existing SongCard platform badge style. Co-Authored-By: Claude Opus 4.7 --- web/src/views/Search.vue | 54 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index 8822277..6d91a3d 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -31,7 +31,10 @@ class="card hover-scale" > -
{{ al.name }}
+
+ {{ al.name }} + {{ badgeLabel(al.platform) }} +
{{ al.artist }}
@@ -47,7 +50,10 @@ class="card hover-scale" > -
{{ pl.name }}
+
+ {{ pl.name }} + {{ badgeLabel(pl.platform) }} +
@@ -111,6 +117,20 @@ async function doSearch() { } } +function badgeLabel(platform: string): string { + if (platform === 'qq') return 'QQ'; + if (platform === 'bilibili') return 'B站'; + if (platform === 'youtube') return 'YouTube'; + return '网易云'; +} + +function badgeClass(platform: string): string { + if (platform === 'qq') return 'badge-qq'; + if (platform === 'bilibili') return 'badge-bilibili'; + if (platform === 'youtube') return 'badge-youtube'; + return 'badge-netease'; +} + onMounted(() => { if (query.value) doSearch(); }); @@ -198,4 +218,34 @@ onMounted(() => { .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; } .card-sub { font-size: 12px; opacity: 0.6; } } + +.platform-badge { + vertical-align: middle; + flex-shrink: 0; + font-size: var(--fs-micro); + font-weight: var(--fw-semi); + padding: 1px 5px; + border-radius: var(--radius-xs); + line-height: 1.4; +} + +.badge-netease { + background: var(--brand-netease-15); + color: var(--brand-netease); +} + +.badge-qq { + background: var(--brand-qq-15); + color: var(--brand-qq); +} + +.badge-bilibili { + background: var(--brand-bilibili-15); + color: var(--brand-bilibili); +} + +.badge-youtube { + background: var(--brand-youtube-12); + color: var(--brand-youtube); +} From 997bb17cebf92448ca1e0d07d60be3235da77f0a Mon Sep 17 00:00:00 2001 From: NoSetViolin Date: Thu, 14 May 2026 16:56:18 +0800 Subject: [PATCH 4/5] feat(search): add tabbed category navigation + align NetEase result counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under the search box, showing only one category at a time with result counts - NetEase album/playlist search limit raised from 5 to 10 to match QQ Co-Authored-By: Claude Opus 4.7 --- src/music/netease.ts | 4 +-- web/src/views/Search.vue | 77 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/music/netease.ts b/src/music/netease.ts index 4a863db..a9060b4 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider { params: { keywords: query, type: 1000, - limit: 5, + limit: 10, ...this.cookieParams, }, }), this.api.get("/cloudsearch", { - params: { keywords: query, type: 10, limit: 5, ...this.cookieParams }, + params: { keywords: query, type: 10, limit: 10, ...this.cookieParams }, }), ]); diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index 6d91a3d..82a976f 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -21,8 +21,31 @@
搜索中...