From d63d95db63f6198bf0b3b022fe9860feac4e17c3 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:50:36 +0700 Subject: [PATCH] fix(lyrics): restore QQ Music search after the desktop CGI broke The desktop search CGI now answers HTTP 500 for every query, so QQ Music never reached its lyrics. Search through the suggestion box instead, rank title and artist matches, and check the length of the best few through the song detail CGI so a live or remixed cut is skipped. The lyrics CGI is unchanged and still answers. --- rust_backend/crates/core/src/lyrics/models.rs | 29 +++- .../providers/src/lyrics/builtin/direct.rs | 126 +++++++++++++----- 2 files changed, 118 insertions(+), 37 deletions(-) diff --git a/rust_backend/crates/core/src/lyrics/models.rs b/rust_backend/crates/core/src/lyrics/models.rs index 5db7ba12..bc8aa535 100644 --- a/rust_backend/crates/core/src/lyrics/models.rs +++ b/rust_backend/crates/core/src/lyrics/models.rs @@ -112,19 +112,34 @@ pub struct QqSong { } go_deserialize!(QqSong { "mid" => mid, "id" => id, "name" => name, "interval" => interval, "singer" => singer, }); #[derive(Clone, Debug, Default)] -pub struct QqSongs { - pub list: Option>, +pub struct QqSongDetail { + pub code: isize, + pub data: Option>, } -go_deserialize!(QqSongs { "list" => list, }); +go_deserialize!(QqSongDetail { "code" => code, "data" => data, }); +/// Search suggestions carry IDs as strings and singers joined by `/`. #[derive(Clone, Debug, Default)] -pub struct QqData { - pub song: QqSongs, +pub struct QqSuggestion { + pub id: String, + pub mid: String, + pub name: String, + pub singer: String, } -go_deserialize!(QqData { "song" => song, }); +go_deserialize!(QqSuggestion { "id" => id, "mid" => mid, "name" => name, "singer" => singer, }); +#[derive(Clone, Debug, Default)] +pub struct QqSuggestedSongs { + pub itemlist: Option>, +} +go_deserialize!(QqSuggestedSongs { "itemlist" => itemlist, }); +#[derive(Clone, Debug, Default)] +pub struct QqSuggestionData { + pub song: QqSuggestedSongs, +} +go_deserialize!(QqSuggestionData { "song" => song, }); #[derive(Clone, Debug, Default)] pub struct QqSearch { pub code: isize, - pub data: QqData, + pub data: QqSuggestionData, } go_deserialize!(QqSearch { "code" => code, "data" => data, }); #[derive(Clone, Debug, Default)] diff --git a/rust_backend/crates/providers/src/lyrics/builtin/direct.rs b/rust_backend/crates/providers/src/lyrics/builtin/direct.rs index 0e1d3b1c..3e2e13c6 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin/direct.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin/direct.rs @@ -65,28 +65,13 @@ impl BuiltinLyricsClient { if query.trim().is_empty() { return Err(LyricsError::NotFound("empty search query".into())); } - let params = pairs(&[ - ("format", "json"), - ("inCharset", "utf8"), - ("outCharset", "utf8"), - ("platform", "yqq.json"), - ("new_json", "1"), - ("w", query.trim()), - ("p", "1"), - ("n", "20"), - ("t", "0"), - ("aggr", "1"), - ("cr", "1"), - ("catZhida", "1"), - ("lossless", "1"), - ("flag_qc", "0"), - ("remoteplace", "txt.yqq.center"), - ("needNewCode", "0"), - ]); + // The desktop search CGI now answers HTTP 500. Search suggestions + // still list songs, without durations, so the song detail CGI checks + // the length of the best few title and artist matches. let response: models::QqSearch = http::decode( &self.direct_body( - "https://c.y.qq.com/soso/fcgi-bin/client_search_cp", - params, + "https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg", + pairs(&[("format", "json"), ("key", query.trim())]), true, check, )?, @@ -99,15 +84,45 @@ impl BuiltinLyricsClient { response.code ))); } - let songs = response.data.song.list.unwrap_or_default(); - let selected = select(&songs, request, |song| { - let artist = artists(song.singer.as_deref().unwrap_or_default()); - let duration = song.interval as f64; - let allowed = matches(&song.name, &artist, duration, request); - (song.name.clone(), artist, duration, allowed) - }) - .filter(|song| !song.mid.trim().is_empty()) - .ok_or_else(|| LyricsError::NotFound("no matching song found on QQ Music".into()))?; + let mut songs: Vec<_> = response + .data + .song + .itemlist + .unwrap_or_default() + .into_iter() + .map(|song| { + let artist = song.singer.replace('/', ", "); + (song, artist) + }) + .filter(|(song, artist)| { + !song.mid.trim().is_empty() && matches(&song.name, artist, 0.0, request) + }) + .collect(); + // Stable, so equal scores keep QQ's own ranking. + songs.sort_by_key(|(song, artist)| { + std::cmp::Reverse(matching::score( + &song.name, + artist, + 0.0, + &request.track, + &request.artist, + request.duration, + )) + }); + let mut selected = None; + for (song, _) in songs.iter().take(3) { + match self.qq_duration(&song.mid, check) { + Ok(Some(duration)) if !matching::duration_matches(duration, request.duration) => {} + Err(error @ LyricsError::Cancelled(_)) => return Err(error), + // A detail outage leaves the length unknown, not mismatched. + _ => { + selected = Some(song); + break; + } + } + } + let selected = selected + .ok_or_else(|| LyricsError::NotFound("no matching song found on QQ Music".into()))?; let params = pairs(&[ ("format", "json"), ("inCharset", "utf8"), @@ -116,7 +131,7 @@ impl BuiltinLyricsClient { ("platform", "yqq.json"), ("needNewCode", "0"), ("songmid", &selected.mid), - ("songid", &selected.id.to_string()), + ("songid", &selected.id), ]); let response: models::QqLyrics = http::decode( &self.direct_body( @@ -148,6 +163,26 @@ impl BuiltinLyricsClient { from_text(&text, "QQ Music", "QQ Music Direct") } + fn qq_duration(&self, mid: &str, check: &Check<'_>) -> Result, LyricsError> { + let response: models::QqSongDetail = http::decode( + &self.direct_body( + "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg", + pairs(&[("format", "json"), ("songmid", mid)]), + true, + check, + )?, + false, + check, + )?; + Ok(response + .data + .unwrap_or_default() + .first() + .filter(|_| response.code == 0) + .map(|song| song.interval as f64) + .filter(|duration| *duration > 0.0)) + } + pub(super) fn kugou( &self, request: &SearchRequest, @@ -394,4 +429,35 @@ mod tests { assert!(base64_text("Z h==", false).is_err()); assert!(base64_text("Zg==", true).is_err()); } + + #[test] + #[ignore = "requires live QQ Music access"] + fn live_qqmusic_matches_by_suggestion_and_checks_length() { + struct Resolver; + impl TrackResolver for Resolver { + fn deezer_id_from_spotify( + &self, + _: &str, + _: &Check<'_>, + ) -> Result { + unreachable!("QQ Music does not resolve tracks") + } + } + let network = NetworkService::new().unwrap(); + let client = BuiltinLyricsClient::new(&network, "", Arc::new(Resolver)); + let mut request = SearchRequest { + track: "晴天".into(), + artist: "周杰伦".into(), + duration: 269.0, + ..SearchRequest::default() + }; + let lyrics = client.qqmusic(&request, &|| Ok(())).unwrap(); + assert!(lyrics.is_synced()); + assert_eq!(lyrics.source, "QQ Music Direct"); + request.duration = 120.0; + assert!(matches!( + client.qqmusic(&request, &|| Ok(())), + Err(LyricsError::NotFound(_)) + )); + } }