|
| 1 | +use std::{borrow::Cow, collections::HashMap}; |
| 2 | + |
| 3 | +use async_trait::async_trait; |
| 4 | +use http::{Method, HeaderMap, HeaderValue, header::{ORIGIN, REFERER, COOKIE}}; |
| 5 | +use log::debug; |
| 6 | +use reqwest::Url; |
| 7 | +use unm_engine::interface::Engine; |
| 8 | +use unm_selector::SimilarSongSelector; |
| 9 | +use unm_types::{Context, RetrievedSongInfo, SerializedIdentifier, Song, SongSearchInformation, Album}; |
| 10 | +use unm_request::{json::{Json, UnableToExtractJson}, request}; |
| 11 | + |
| 12 | +pub const ENGINE_ID: &str = "qq"; |
| 13 | + |
| 14 | +pub struct QQEngine; |
| 15 | + |
| 16 | +#[async_trait] |
| 17 | +impl Engine for QQEngine { |
| 18 | + async fn search<'a>( |
| 19 | + &self, |
| 20 | + info: &'a Song, |
| 21 | + ctx: &'a Context, |
| 22 | + ) -> anyhow::Result<Option<SongSearchInformation<'static>>> { |
| 23 | + log::info!("Searching with QQ engine"); |
| 24 | + |
| 25 | + let response = get_search_data(&info.keyword(), ctx).await?; |
| 26 | + let result = response |
| 27 | + .pointer("/data/song/list") |
| 28 | + .ok_or_else(|| anyhow::anyhow!("/data/song/list not found"))? |
| 29 | + .as_array() |
| 30 | + .ok_or(UnableToExtractJson { |
| 31 | + json_pointer: "/data/song/list", |
| 32 | + expected_type: "array", |
| 33 | + })?; |
| 34 | + |
| 35 | + let matched = find_match(info, result).await?; |
| 36 | + |
| 37 | + if let Some(song) = matched { |
| 38 | + Ok(Some(SongSearchInformation { |
| 39 | + source: Cow::Borrowed(ENGINE_ID), |
| 40 | + identifier: song.id.to_string(), |
| 41 | + song: Some(song), |
| 42 | + })) |
| 43 | + } else { |
| 44 | + Ok(None) |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + |
| 49 | + async fn retrieve<'a>( |
| 50 | + &self, |
| 51 | + identifier: &'a SerializedIdentifier, |
| 52 | + ctx: &'a Context, |
| 53 | + ) -> anyhow::Result<RetrievedSongInfo<'static>> { |
| 54 | + todo!() |
| 55 | + } |
| 56 | + |
| 57 | +} |
| 58 | + |
| 59 | +async fn get_search_data(keyword: &str, ctx: &Context) -> anyhow::Result<Json> { |
| 60 | + debug!("Getting the search data from QQ Music…"); |
| 61 | + |
| 62 | + let url = construct_search_url(keyword)?; |
| 63 | + let cookie = get_cookie(ctx); |
| 64 | + let res = request( |
| 65 | + Method::GET, |
| 66 | + &url, |
| 67 | + Some(construct_header(cookie)?), |
| 68 | + None, |
| 69 | + ctx.try_get_proxy()? |
| 70 | + ) |
| 71 | + .await?; |
| 72 | + Ok(res.json().await?) |
| 73 | +} |
| 74 | + |
| 75 | +fn get_cookie(context: &Context) -> Option<&str> { |
| 76 | + if let Some(ref config) = context.config { |
| 77 | + config.get_deref(Cow::Borrowed("qq:cookie")) |
| 78 | + } else { |
| 79 | + None |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +async fn find_match(info: &Song, data: &[Json]) -> anyhow::Result<Option<Song>> { |
| 84 | + let selector = SimilarSongSelector::new(info).optional_selector; |
| 85 | + let similar_song = data |
| 86 | + .iter() |
| 87 | + .map(|entry| format(entry).ok()) |
| 88 | + .find(|s| selector(&s)) |
| 89 | + .expect("shoule be Some"); |
| 90 | + |
| 91 | + Ok(similar_song) |
| 92 | +} |
| 93 | + |
| 94 | +fn format(song: &Json) -> anyhow::Result<Song> { |
| 95 | + debug!("Formatting the response to Song…"); |
| 96 | + |
| 97 | + let id = song["songid"].as_i64().ok_or(UnableToExtractJson { |
| 98 | + json_pointer: "/songid", |
| 99 | + expected_type: "i64", |
| 100 | + })?; |
| 101 | + let name = song["songname"].as_str().ok_or(UnableToExtractJson { |
| 102 | + json_pointer: "/songname", |
| 103 | + expected_type: "string", |
| 104 | + })?; |
| 105 | + let duration = song["interval"].as_i64().ok_or(UnableToExtractJson { |
| 106 | + json_pointer: "/interval", |
| 107 | + expected_type: "i64", |
| 108 | + })?; |
| 109 | + let album_id = song["albumid"].as_i64().ok_or(UnableToExtractJson { |
| 110 | + json_pointer: "/albumid", |
| 111 | + expected_type: "i64", |
| 112 | + })?; |
| 113 | + let album_name = song["albumname"].as_str().ok_or(UnableToExtractJson { |
| 114 | + json_pointer: "/albumname", |
| 115 | + expected_type: "string", |
| 116 | + })?; |
| 117 | + |
| 118 | + let media_mid = song["media_mid"].as_str().ok_or(UnableToExtractJson { |
| 119 | + json_pointer: "/media_mid", |
| 120 | + expected_type: "string", |
| 121 | + })?; |
| 122 | + let song_mid = song["songmid"].as_str().ok_or(UnableToExtractJson { |
| 123 | + json_pointer: "/songmid", |
| 124 | + expected_type: "string", |
| 125 | + })?; |
| 126 | + let mut context = HashMap::new(); |
| 127 | + context.insert("media_mid".to_string(), media_mid.to_string()); |
| 128 | + context.insert("songmid".to_string(), song_mid.to_string()); |
| 129 | + |
| 130 | + let x = Song { |
| 131 | + id: id.to_string(), |
| 132 | + name: String::from(name), |
| 133 | + duration: Some(duration * 1000), |
| 134 | + artists: vec![], |
| 135 | + album: Some(Album { |
| 136 | + id: album_id.to_string(), |
| 137 | + name: String::from(album_name), |
| 138 | + }), |
| 139 | + context: Some(context), |
| 140 | + }; |
| 141 | + |
| 142 | + Ok(x) |
| 143 | +} |
| 144 | + |
| 145 | +fn construct_header(cookie: Option<&str>) -> anyhow::Result<HeaderMap> { |
| 146 | + log::debug!("Constructing the header for QQ Music…"); |
| 147 | + |
| 148 | + let mut hm = HeaderMap::new(); |
| 149 | + |
| 150 | + hm.insert(ORIGIN, HeaderValue::from_static("http://y.qq.com")); |
| 151 | + hm.insert(REFERER, HeaderValue::from_static("http://y.qq.com")); |
| 152 | + |
| 153 | + if let Some(cookie) = cookie { |
| 154 | + hm.insert(COOKIE, HeaderValue::from_str(cookie)?); |
| 155 | + } |
| 156 | + |
| 157 | + Ok(hm) |
| 158 | +} |
| 159 | + |
| 160 | +fn construct_search_url(keyword: &str) -> anyhow::Result<Url> { |
| 161 | + Ok(Url::parse_with_params( |
| 162 | + "https://c.y.qq.com/soso/fcgi-bin/client_search_cp?", |
| 163 | + &[ |
| 164 | + ("ct", "24"), |
| 165 | + ("qqmusic_ver", "1298"), |
| 166 | + ("remoteplace", "txt.yqq.center"), |
| 167 | + ("t", "0"), |
| 168 | + ("aggr", "1"), |
| 169 | + ("cr", "1"), |
| 170 | + ("catZhida", "1"), |
| 171 | + ("lossless", "1"), |
| 172 | + ("flag_qc", "0"), |
| 173 | + ("p", "1"), |
| 174 | + ("n", "20"), |
| 175 | + ("w", keyword), |
| 176 | + ("g_tk", "5381"), |
| 177 | + ("loginUin", "0"), |
| 178 | + ("hostUin", "0"), |
| 179 | + ("format", "json"), |
| 180 | + ("inCharset", "utf8"), |
| 181 | + ("outCharset", "utf-8"), |
| 182 | + ("notice", "0"), |
| 183 | + ("platform", "yqq"), |
| 184 | + ("needNewCode", "0") |
| 185 | + ] |
| 186 | + )?) |
| 187 | +} |
| 188 | + |
| 189 | +#[cfg(test)] |
| 190 | +mod tests { |
| 191 | + use tokio::test; |
| 192 | + use unm_types::{Context, Artist}; |
| 193 | + |
| 194 | + use super::*; |
| 195 | + |
| 196 | + fn get_info_1() -> Song { |
| 197 | + // https://music.163.com/api/song/detail?ids=[385552] |
| 198 | + Song { |
| 199 | + name: String::from("干杯"), |
| 200 | + artists: vec![Artist { |
| 201 | + name: String::from("五月天"), |
| 202 | + ..Default::default() |
| 203 | + }], |
| 204 | + ..Default::default() |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + #[test] |
| 209 | + async fn qq_search() { |
| 210 | + let info = get_info_1(); |
| 211 | + let info = QQEngine |
| 212 | + .search(&info, &Context::default()) |
| 213 | + .await |
| 214 | + .unwrap() |
| 215 | + .unwrap(); |
| 216 | + |
| 217 | + assert_eq!(info.identifier, "1056382"); |
| 218 | + assert_eq!(info.source, ENGINE_ID); |
| 219 | + } |
| 220 | +} |
0 commit comments