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(_)) + )); + } }