├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── api.rs ├── client ├── api_request.rs ├── api_response.rs ├── mod.rs ├── route.rs └── store.rs ├── crypto ├── key.rs └── mod.rs ├── lib.rs └── types └── mod.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | test-data -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ncmapi" 3 | version = "0.1.13" 4 | authors = ["akatsuki "] 5 | edition = "2018" 6 | description = "NetEase Cloud Music API for Rust." 7 | license = "MIT" 8 | homepage = "https://github.com/two-mountains/ncmapi-rs" 9 | documentation = "https://docs.rs/ncmapi" 10 | repository = "https://github.com/two-mountains/ncmapi-rs" 11 | readme = "README.md" 12 | keywords = ["netease-cloud-muisc", "api"] 13 | categories = ["api-bindings"] 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | reqwest = { version = "0.11", features = ["json", "cookies"] } 19 | tokio = { version = "1", features = ["full"] } 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_repr = "0.1" 22 | serde_json = "1.0" 23 | openssl = "0.10" 24 | hex = "0.4" 25 | rand = "0.8" 26 | base64 = "0.13" 27 | memory-cache-rs = "0.2.0" 28 | cookie = "0.15" 29 | regex = "1.5" 30 | phf = { version = "0.9", features = ["macros"] } 31 | thiserror = "1" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 TMs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ncmapi-rs

2 | 3 | NetEase Cloud Music API for Rust. 4 | 5 | 6 | ### Usage 7 | 8 | ```toml 9 | [dependencies] 10 | ncmapi = "0.1" 11 | tokio = { version = "1", features = ["full"] } 12 | ``` 13 | 14 | ```rust 15 | use ncmapi::NcmApi; 16 | 17 | #[tokio::main] 18 | async fn main() -> std::result::Result<(), Box> { 19 | let api = NcmApi::default(); 20 | let resp = api.cloud_search("mota", None).await; 21 | 22 | let res = resp.unwrap().deserialize_to_implict(); 23 | println!("{:#?}", res); 24 | 25 | Ok(()) 26 | } 27 | ``` 28 | 29 | 30 | ### Document 31 | 32 | Most of the functions are self documented. If there is some confusion about the params of a funtion requires, figure out [here](https://neteasecloudmusicapi.vercel.app) 33 | 34 | 35 | 36 | ### How it works 37 | 38 | * api: export api functions. 39 | * client: 40 | * takes an ApiRequst, process it into a Request by presenting it with header and encrypt the payload etc. And then send requests to the server, takes the response and then returns the ApiResponse back. 41 | * cache 42 | 43 | ### Contribute 44 | 45 | If you think this package useful, please do make pull requests. 46 | 47 | ### License 48 | 49 | [MIT](LICENSE) -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use std::{time::Duration, usize}; 2 | 3 | use openssl::hash::{hash, MessageDigest}; 4 | use rand::{Rng, RngCore}; 5 | use serde_json::{json, Value}; 6 | use serde_repr::{Deserialize_repr, Serialize_repr}; 7 | 8 | use crate::{ 9 | client::{ApiClient, ApiClientBuilder, ApiRequestBuilder, ApiResponse, API_ROUTE}, 10 | TResult, 11 | }; 12 | 13 | /// API wrapper. 14 | #[derive(Default)] 15 | pub struct NcmApi { 16 | client: ApiClient, 17 | } 18 | 19 | impl NcmApi { 20 | /// NecmApi constructor 21 | pub fn new( 22 | enable_cache: bool, 23 | cache_exp: Duration, 24 | cache_clean_interval: Duration, 25 | preserve_cookies: bool, 26 | cookie_path: &str, 27 | ) -> Self { 28 | Self { 29 | client: ApiClientBuilder::new(cookie_path) 30 | .cookie_path(cookie_path) 31 | .cache(enable_cache) 32 | .cache_exp(cache_exp) 33 | .cache_clean_interval(cache_clean_interval) 34 | .preserve_cookies(preserve_cookies) 35 | .build() 36 | .unwrap(), 37 | } 38 | } 39 | } 40 | 41 | /// apis 42 | impl NcmApi { 43 | async fn _search(&self, key: &str, route: &str, opt: Option) -> TResult { 44 | let r = ApiRequestBuilder::post(API_ROUTE[route]) 45 | .set_data(limit_offset(30, 0)) 46 | .merge(json!({ 47 | "s": key, 48 | "type": 1, 49 | })) 50 | .merge(opt.unwrap_or_default()) 51 | .build(); 52 | 53 | self.client.request(r).await 54 | } 55 | 56 | /// 说明 : 调用此接口 , 传入搜索关键词可以搜索该音乐 / 专辑 / 歌手 / 歌单 / 用户 , 关键词可以多个 , 以空格隔开 , 57 | /// 如 " 周杰伦 搁浅 "( 不需要登录 ), 搜索获取的 mp3url 不能直接用 , 可通过 /song/url 接口传入歌曲 id 获取具体的播放链接 58 | /// 59 | /// required 60 | /// 必选参数 : key: 关键词 61 | /// 62 | /// optional 63 | /// 可选参数 : limit : 返回数量 , 默认为 30 offset : 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 64 | /// type: 搜索类型;默认为 1 即单曲 , 取值意义 : 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频, 1018:综合 65 | pub async fn search(&self, key: &str, opt: Option) -> TResult { 66 | self._search(key, "cloudsearch", opt).await 67 | } 68 | 69 | /// 说明 : 调用此接口,可收藏/取消收藏专辑 70 | /// required 71 | /// id : 专辑 id 72 | /// t : 1 为收藏,其他为取消收藏 73 | pub async fn album_sub(&self, id: usize, op: u8) -> TResult { 74 | let op = if op == 1 { "sub" } else { "unsub" }; 75 | let u = replace_all_route_params(API_ROUTE["album_sub"], op); 76 | let r = ApiRequestBuilder::post(&u) 77 | .set_data(json!({ 78 | "id": id, 79 | })) 80 | .build(); 81 | 82 | self.client.request(r).await 83 | } 84 | 85 | /// 说明 : 调用此接口 , 可获得已收藏专辑列表 86 | /// optional 87 | /// limit: 取出数量 , 默认为 25 88 | /// offset: 偏移数量 , 用于分页 , 如 :( 页数 -1)*25, 其中 25 为 limit 的值 , 默认 为 0 89 | pub async fn album_sublist(&self, opt: Option) -> TResult { 90 | let r = ApiRequestBuilder::post(API_ROUTE["album_sublist"]) 91 | .set_data(limit_offset(25, 0)) 92 | .insert("total", Value::Bool(true)) 93 | .merge(opt.unwrap_or_default()) 94 | .build(); 95 | 96 | self.client.request(r).await 97 | } 98 | 99 | /// 说明 : 调用此接口 , 传入专辑 id, 可获得专辑内容 100 | /// required 101 | /// 必选参数 : id: 专辑 id 102 | pub async fn album(&self, id: usize) -> TResult { 103 | let u = replace_all_route_params(API_ROUTE["album"], &id.to_string()); 104 | let r = ApiRequestBuilder::post(&u).build(); 105 | 106 | self.client.request(r).await 107 | } 108 | 109 | /// 说明 : 调用此接口,可获取歌手全部歌曲 必选参数 : 110 | /// required 111 | /// id : 歌手 id 112 | /// optional: 113 | /// order : hot ,time 按照热门或者时间排序 114 | /// limit: 取出歌单数量 , 默认为 50 115 | /// offset: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)*50, 其中 50 为 limit 的值 116 | pub async fn artist_songs(&self, id: usize, opt: Option) -> TResult { 117 | let r = ApiRequestBuilder::post(API_ROUTE["artist_songs"]) 118 | .set_data(json!({ 119 | "id": id, 120 | "private_cloud": true, 121 | "work_type": 1, 122 | "order": "hot", 123 | "offset": 0, 124 | "limit": 100, 125 | })) 126 | .merge(opt.unwrap_or_default()) 127 | .add_cookie("os", "pc") 128 | .build(); 129 | 130 | self.client.request(r).await 131 | } 132 | 133 | /// 说明 : 调用此接口,可收藏歌手 134 | /// required 135 | /// id : 歌手 id 136 | /// t:操作,1 为收藏,其他为取消收藏 137 | pub async fn artist_sub(&self, id: usize, sub: u8) -> TResult { 138 | let mut opt = "sub"; 139 | if sub != 1 { 140 | opt = "unsub"; 141 | } 142 | 143 | let u = replace_all_route_params(API_ROUTE["artist_sub"], opt); 144 | let r = ApiRequestBuilder::post(&u) 145 | .set_data(json!({ 146 | "artistId": id, 147 | "artistIds": [id] 148 | })) 149 | .build(); 150 | 151 | self.client.request(r).await 152 | } 153 | 154 | /// 说明 : 调用此接口,可获取收藏的歌手列表 155 | pub async fn artist_sublist(&self, opt: Option) -> TResult { 156 | let r = ApiRequestBuilder::post(API_ROUTE["artist_sublist"]) 157 | .set_data(limit_offset(25, 0)) 158 | .merge(opt.unwrap_or_default()) 159 | .insert("total", Value::Bool(true)) 160 | .build(); 161 | 162 | self.client.request(r).await 163 | } 164 | 165 | /// 说明 : 调用此接口,可获取歌手热门50首歌曲 166 | /// required 167 | /// id : 歌手 id 168 | pub async fn artist_top_song(&self, id: usize) -> TResult { 169 | let r = ApiRequestBuilder::post(API_ROUTE["artist_top_song"]) 170 | .set_data(json!({ "id": id })) 171 | .build(); 172 | 173 | self.client.request(r).await 174 | } 175 | 176 | /// 说明: 调用此接口,传入歌曲 id, 可获取音乐是否可用,返回 { success: true, message: 'ok' } 或者 { success: false, message: '亲爱的,暂无版权' } 177 | /// requried 178 | /// 必选参数 : id : 歌曲 id 179 | /// optional 180 | /// 可选参数 : br: 码率,默认设置了 999000 即最大码率,如果要 320k 则可设置为 320000,其他类推 181 | pub async fn check_music(&self, id: usize, opt: Option) -> TResult { 182 | let r = ApiRequestBuilder::post(API_ROUTE["check_music"]) 183 | .set_data(json!({"br": 999000})) 184 | .merge(opt.unwrap_or_default()) 185 | .merge(json!({ "ids": [id] })) 186 | .build(); 187 | 188 | self.client.request(r).await 189 | } 190 | 191 | /// 说明 : 调用此接口 , 传入 type, 资源 id 可获得对应资源热门评论 ( 不需要登录 ) 192 | /// required 193 | /// id : 资源 id 194 | /// type: 数字 , 资源类型 195 | /// 196 | /// optional 197 | /// 可选参数 : limit: 取出评论数量 , 默认为 20 198 | /// offset: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)*20, 其中 20 为 limit 的值 199 | /// before: 分页参数,取上一页最后一项的 time 获取下一页数据(获取超过5000条评论的时候需要用到) 200 | pub async fn comment_hot( 201 | &self, 202 | id: usize, 203 | resouce_type: ResourceType, 204 | opt: Option, 205 | ) -> TResult { 206 | let u = replace_all_route_params(API_ROUTE["comment_hot"], ""); 207 | let u = format!("{}{}{}", u, map_resource_code(resouce_type), id); 208 | 209 | let r = ApiRequestBuilder::post(&u) 210 | .add_cookie("os", "pc") 211 | .set_data(limit_offset(20, 0)) 212 | .merge(opt.unwrap_or_default()) 213 | .merge(json!({ 214 | "beforeTime": 0, 215 | "rid": id 216 | })) 217 | .build(); 218 | 219 | self.client.request(r).await 220 | } 221 | 222 | /// 新版评论接口 223 | /// 说明 : 调用此接口 , 传入资源类型和资源id,以及排序方式,可获取对应资源的评论 224 | /// 225 | /// required 226 | /// id : 资源 id, 如歌曲 id,mv id 227 | /// type: 数字 , 资源类型 , 对应歌曲 , mv, 专辑 , 歌单 , 电台, 视频对应以下类型 228 | /// 229 | /// optional 230 | /// pageNo:分页参数,第N页,默认为1 231 | /// pageSize:分页参数,每页多少条数据,默认20 232 | /// sortType: 排序方式,1:按推荐排序,2:按热度排序,3:按时间排序 233 | /// cursor: 当sortType为3时且页数不是第一页时需传入,值为上一条数据的time 234 | #[allow(clippy::too_many_arguments)] 235 | pub async fn comment( 236 | &self, 237 | id: usize, 238 | resource_type: ResourceType, 239 | page_size: usize, 240 | page_no: usize, 241 | sort_type: usize, 242 | cursor: usize, 243 | show_inner: bool, 244 | ) -> TResult { 245 | let mut cursor = cursor; 246 | if sort_type != 3 { 247 | cursor = (page_no - 1) * page_size; 248 | } 249 | 250 | let r = ApiRequestBuilder::post(API_ROUTE["comment_new"]) 251 | .set_crypto(crate::crypto::Crypto::Eapi) 252 | .add_cookie("os", "pc") 253 | .set_api_url("/api/v2/resource/comments") 254 | .set_data(json!({ 255 | "pageSize": page_size, 256 | "pageNo": page_no, 257 | "sortType": sort_type, 258 | "cursor": cursor, 259 | "showInner": show_inner, 260 | })) 261 | .insert( 262 | "threadId", 263 | Value::String(format!("{}{}", map_resource_code(resource_type), id)), 264 | ) 265 | .build(); 266 | 267 | self.client.request(r).await 268 | } 269 | 270 | /// required 271 | /// rid: resource id 272 | /// rt: resource type 273 | /// cmt: comment body 274 | pub async fn comment_create( 275 | &self, 276 | rid: usize, 277 | rt: ResourceType, 278 | cmt: &str, 279 | ) -> TResult { 280 | let thread_id = format!("{}{}", map_resource_code(rt), rid); 281 | 282 | let u = replace_all_route_params(API_ROUTE["comment"], "add"); 283 | let r = ApiRequestBuilder::post(&u) 284 | .add_cookie("os", "pc") 285 | .set_data(json!({"threadId": thread_id, "content": cmt})) 286 | .build(); 287 | 288 | self.client.request(r).await 289 | } 290 | 291 | /// required 292 | /// rid: resource id 293 | /// rt: resource type 294 | /// reid: the comment id of reply to 295 | /// cmt: comment body 296 | pub async fn comment_re( 297 | &self, 298 | rid: usize, 299 | rt: ResourceType, 300 | re_id: usize, 301 | cmt: &str, 302 | ) -> TResult { 303 | let thread_id = format!("{}{}", map_resource_code(rt), rid); 304 | 305 | let u = replace_all_route_params(API_ROUTE["comment"], "reply"); 306 | let r = ApiRequestBuilder::post(&u) 307 | .add_cookie("os", "pc") 308 | .set_data(json!({"threadId": thread_id, "content": cmt, "commentId": re_id})) 309 | .build(); 310 | 311 | self.client.request(r).await 312 | } 313 | 314 | /// required 315 | /// rid: resource id 316 | /// rt: resource type 317 | /// cmtid: comment id 318 | pub async fn comment_del( 319 | &self, 320 | rid: usize, 321 | rt: ResourceType, 322 | cmt_id: usize, 323 | ) -> TResult { 324 | let thread_id = format!("{}{}", map_resource_code(rt), rid); 325 | 326 | let u = replace_all_route_params(API_ROUTE["comment"], "delete"); 327 | let r = ApiRequestBuilder::post(&u) 328 | .add_cookie("os", "pc") 329 | .set_data(json!({"threadId": thread_id, "commentId": cmt_id})) 330 | .build(); 331 | 332 | self.client.request(r).await 333 | } 334 | 335 | /// 说明 : 调用此接口 , 传入签到类型 ( 可不传 , 默认安卓端签到 ), 可签到 ( 需要登录 ), 其中安卓端签到可获得 3 点经验 , web/PC 端签到可获得 2 点经验 336 | /// 337 | /// optional 338 | /// 可选参数 : type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到 339 | pub async fn daily_signin(&self, opt: Option) -> TResult { 340 | let r = ApiRequestBuilder::post(API_ROUTE["daily_signin"]) 341 | .set_data(json!({"type": 0})) 342 | .merge(opt.unwrap_or_default()) 343 | .build(); 344 | 345 | self.client.request(r).await 346 | } 347 | 348 | /// 说明 : 调用此接口 , 传入音乐 id, 可把该音乐从私人 FM 中移除至垃圾桶 349 | /// 350 | /// required 351 | /// id: 歌曲 id 352 | pub async fn fm_trash(&self, id: usize) -> TResult { 353 | let mut rng = rand::thread_rng(); 354 | let u = format!( 355 | "https://music.163.com/weapi/radio/trash/add?alg=RT&songId={}&time={}", 356 | id, 357 | rng.gen_range(10..20) 358 | ); 359 | let r = ApiRequestBuilder::post(&u) 360 | .set_data(json!({ "songId": id })) 361 | .build(); 362 | 363 | self.client.request(r).await 364 | } 365 | 366 | /// 说明 : 调用此接口 , 传入音乐 id, 可喜欢该音乐 367 | /// 368 | /// required 369 | /// 必选参数 : id: 歌曲 id 370 | /// 371 | /// optional 372 | /// 可选参数 : like: 布尔值 , 默认为 true 即喜欢 , 若传 false, 则取消喜欢 373 | pub async fn like(&self, id: usize, opt: Option) -> TResult { 374 | let r = ApiRequestBuilder::post(API_ROUTE["like"]) 375 | .add_cookie("os", "pc") 376 | .add_cookie("appver", "2.7.1.198277") 377 | .set_real_ip("118.88.88.88") 378 | .set_data(json!({"alg": "itembased", "time": 3, "like": true, "trackId": id})) 379 | .merge(opt.unwrap_or_default()) 380 | .build(); 381 | 382 | self.client.request(r).await 383 | } 384 | 385 | /// 说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐id列表(id数组) 386 | /// 387 | /// required 388 | /// 必选参数 : uid: 用户 id 389 | pub async fn likelist(&self, uid: usize) -> TResult { 390 | let r = ApiRequestBuilder::post(API_ROUTE["likelist"]) 391 | .set_data(json!({ "uid": uid })) 392 | .build(); 393 | 394 | self.client.request(r).await 395 | } 396 | 397 | /// 必选参数 : 398 | /// phone: 手机号码 399 | /// password: 密码 400 | /// 401 | /// 可选参数 : 402 | /// countrycode: 国家码,用于国外手机号登录,例如美国传入:1 403 | /// md5_password: md5加密后的密码,传入后 password 将失效 404 | pub async fn login_phone(&self, phone: &str, password: &str) -> TResult { 405 | let password = md5_hex(password.as_bytes()); 406 | let r = ApiRequestBuilder::post(API_ROUTE["login_cellphone"]) 407 | .add_cookie("os", "pc") 408 | .add_cookie("appver", "2.9.7") 409 | .set_data(json!({ 410 | "countrycode": "86", 411 | "rememberLogin": "true", 412 | "phone": phone, 413 | "password": password, 414 | })) 415 | .build(); 416 | 417 | self.client.request(r).await 418 | } 419 | 420 | /// 说明 : 调用此接口 , 可刷新登录状态 421 | pub async fn login_refresh(&self) -> TResult { 422 | let r = ApiRequestBuilder::post(API_ROUTE["login_refresh"]).build(); 423 | 424 | self.client.request(r).await 425 | } 426 | 427 | /// 说明 : 调用此接口,可获取登录状态 428 | pub async fn login_status(&self) -> TResult { 429 | let r = ApiRequestBuilder::post(API_ROUTE["login_status"]).build(); 430 | 431 | self.client.request(r).await 432 | } 433 | 434 | /// 说明 : 调用此接口 , 可退出登录 435 | pub async fn logout(&self) -> TResult { 436 | let r = ApiRequestBuilder::post(API_ROUTE["logout"]).build(); 437 | 438 | self.client.request(r).await 439 | } 440 | 441 | /// 说明 : 调用此接口 , 传入音乐 id 可获得对应音乐的歌词 ( 不需要登录 ) 442 | /// 443 | /// required 444 | /// 必选参数 : id: 音乐 id 445 | pub async fn lyric(&self, id: usize) -> TResult { 446 | let r = ApiRequestBuilder::post(API_ROUTE["lyric"]) 447 | .add_cookie("os", "pc") 448 | .set_data(json!({ 449 | "id": id, 450 | "lv": -1, 451 | "kv": -1, 452 | "tv": -1, 453 | })) 454 | .build(); 455 | 456 | self.client.request(r).await 457 | } 458 | 459 | /// 说明 : 私人 FM( 需要登录 ) 460 | pub async fn personal_fm(&self) -> TResult { 461 | let r = ApiRequestBuilder::post(API_ROUTE["personal_fm"]).build(); 462 | 463 | self.client.request(r).await 464 | } 465 | 466 | /// 说明 : 歌单能看到歌单名字, 但看不到具体歌单内容 , 调用此接口 , 传入歌单 id, 467 | /// 可以获取对应歌单内的所有的音乐(未登录状态只能获取不完整的歌单,登录后是完整的), 468 | /// 但是返回的trackIds是完整的,tracks 则是不完整的, 469 | /// 可拿全部 trackIds 请求一次 song/detail 接口获取所有歌曲的详情 470 | /// 471 | /// required 472 | /// 必选参数 : id : 歌单 id 473 | /// 474 | /// optional 475 | /// 可选参数 : s : 歌单最近的 s 个收藏者,默认为8 476 | pub async fn playlist_detail(&self, id: usize, opt: Option) -> TResult { 477 | let r = ApiRequestBuilder::post(API_ROUTE["playlist_detail"]) 478 | .set_data(json!({"n": 100000, "s": 8, "id": id})) 479 | .merge(opt.unwrap_or_default()) 480 | .build(); 481 | 482 | self.client.request(r).await 483 | } 484 | 485 | /// 说明 : 调用此接口 , 可以添加歌曲到歌单或者从歌单删除某首歌曲 ( 需要登录 ) 486 | /// 487 | /// required 488 | /// op: 从歌单增加单曲为 add, 删除为 del 489 | /// pid: 歌单 id 490 | /// tracks: 歌曲 id,可多个,用逗号隔开 491 | pub async fn playlist_tracks( 492 | &self, 493 | pid: usize, 494 | op: u8, 495 | tracks: Vec, 496 | ) -> TResult { 497 | let op = if op == 1 { "add" } else { "del" }; 498 | let r = ApiRequestBuilder::post(API_ROUTE["playlist_tracks"]) 499 | .add_cookie("os", "pc") 500 | .set_data(json!({"op": op, "pid": pid, "trackIds": tracks, "imme": true})) 501 | .build(); 502 | 503 | self.client.request(r).await 504 | } 505 | 506 | /// 说明 : 登录后调用此接口,可以更新用户歌单 507 | /// 508 | /// required 509 | /// id:歌单id 510 | /// name:歌单名字 511 | /// desc:歌单描述 512 | /// tags:歌单tag ,多个用 `;` 隔开,只能用官方规定标签 513 | pub async fn playlist_update( 514 | &self, 515 | pid: usize, 516 | name: &str, 517 | desc: &str, 518 | tags: Vec<&str>, 519 | ) -> TResult { 520 | let r = ApiRequestBuilder::post(API_ROUTE["playlist_update"]) 521 | .add_cookie("os", "pc") 522 | .set_data(json!({ 523 | "/api/playlist/update/name": {"id": pid, "name": name}, 524 | "/api/playlist/desc/update": {"id": pid, "desc": desc}, 525 | "/api/playlist/tags/update": {"id": pid, "tags": tags.join(";")}, 526 | })) 527 | .build(); 528 | 529 | self.client.request(r).await 530 | } 531 | 532 | /// 说明 : 调用此接口 , 可获得每日推荐歌单 ( 需要登录 ) 533 | pub async fn recommend_resource(&self) -> TResult { 534 | let r = ApiRequestBuilder::post(API_ROUTE["recommend_resource"]).build(); 535 | 536 | self.client.request(r).await 537 | } 538 | 539 | /// 说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 ) 540 | pub async fn recommend_songs(&self) -> TResult { 541 | let r = ApiRequestBuilder::post(API_ROUTE["recommend_songs"]) 542 | .add_cookie("os", "ios") 543 | .build(); 544 | 545 | self.client.request(r).await 546 | } 547 | 548 | /// 说明 : 调用此接口 , 传入音乐 id, 来源 id,歌曲时间 time,更新听歌排行数据 549 | /// 550 | /// requried 551 | /// 必选参数 : 552 | /// id: 歌曲 id 553 | /// sourceid: 歌单或专辑 id 554 | /// 555 | /// optional 556 | /// 可选参数 : time: 歌曲播放时间,单位为秒 557 | pub async fn scrobble(&self, id: usize, source_id: usize) -> TResult { 558 | let mut rng = rand::thread_rng(); 559 | let r = ApiRequestBuilder::post(API_ROUTE["scrobble"]) 560 | .set_data(json!({ 561 | "logs": [{ 562 | "action": "play", 563 | "json": { 564 | "download": 0, 565 | "end": "playend", 566 | "id": id, 567 | "sourceId": source_id, 568 | "time": rng.gen_range(20..30), 569 | "type": "song", 570 | "wifi": 0, 571 | } 572 | }] 573 | })) 574 | .build(); 575 | 576 | self.client.request(r).await 577 | } 578 | 579 | /// 说明 : 调用此接口 , 可获取默认搜索关键词 580 | pub async fn search_default(&self) -> TResult { 581 | let r = ApiRequestBuilder::post(API_ROUTE["search_default"]) 582 | .set_crypto(crate::crypto::Crypto::Eapi) 583 | .set_api_url("/api/search/defaultkeyword/get") 584 | .build(); 585 | 586 | self.client.request(r).await 587 | } 588 | 589 | /// 说明 : 调用此接口,可获取热门搜索列表 590 | pub async fn search_hot_detail(&self) -> TResult { 591 | let r = ApiRequestBuilder::post(API_ROUTE["search_hot_detail"]).build(); 592 | 593 | self.client.request(r).await 594 | } 595 | 596 | /// 说明 : 调用此接口,可获取热门搜索列表(简略) 597 | pub async fn search_hot(&self) -> TResult { 598 | let r = ApiRequestBuilder::post(API_ROUTE["search_hot"]) 599 | .set_data(json!({"type": 1111})) 600 | .set_ua(crate::client::UA::IPhone) 601 | .build(); 602 | 603 | self.client.request(r).await 604 | } 605 | 606 | /// 说明 : 调用此接口 , 传入搜索关键词可获得搜索建议 , 搜索结果同时包含单曲 , 歌手 , 歌单 ,mv 信息 607 | /// 608 | /// required 609 | /// 必选参数 : keywords : 关键词 610 | /// 611 | /// optional 612 | /// 可选参数 : type : 如果传 'mobile' 则返回移动端数据 613 | pub async fn search_suggest(&self, keyword: &str, opt: Option) -> TResult { 614 | let mut device = "web"; 615 | if let Some(val) = opt { 616 | if val["type"] == "mobile" { 617 | device = "mobile" 618 | } 619 | } 620 | 621 | let u = format!("{}{}", API_ROUTE["search_suggest"], device); 622 | let r = ApiRequestBuilder::post(&u) 623 | .set_data(json!({ "s": keyword })) 624 | .build(); 625 | 626 | self.client.request(r).await 627 | } 628 | 629 | /// 说明 : 调用此接口 , 传入歌手 id, 可获得相似歌手 630 | /// 631 | /// requried 632 | /// 必选参数 : id: 歌手 id 633 | pub async fn simi_artist(&self, artist_id: usize) -> TResult { 634 | let mut r = ApiRequestBuilder::post(API_ROUTE["simi_artist"]) 635 | .set_data(json!({ "artistid": artist_id })); 636 | if self 637 | .client 638 | .cookie("MUSIC_U", self.client.base_url()) 639 | .is_none() 640 | { 641 | r = r.add_cookie("MUSIC_A", ANONYMOUS_TOKEN); 642 | } 643 | 644 | self.client.request(r.build()).await 645 | } 646 | 647 | /// 说明 : 调用此接口 , 传入歌曲 id, 可获得相似歌单 648 | /// 649 | /// required 650 | /// 必选参数 : id: 歌曲 id 651 | pub async fn simi_playlist(&self, id: usize, opt: Option) -> TResult { 652 | let r = ApiRequestBuilder::post(API_ROUTE["simi_playlist"]) 653 | .set_data(limit_offset(50, 0)) 654 | .merge(opt.unwrap_or_default()) 655 | .insert("songid", json!(id)) 656 | .build(); 657 | 658 | self.client.request(r).await 659 | } 660 | 661 | /// 说明 : 调用此接口 , 传入歌曲 id, 可获得相似歌曲 662 | /// 663 | /// required 664 | /// 必选参数 : id: 歌曲 id 665 | pub async fn simi_song(&self, id: usize, opt: Option) -> TResult { 666 | let r = ApiRequestBuilder::post(API_ROUTE["simi_song"]) 667 | .set_data(limit_offset(50, 0)) 668 | .merge(opt.unwrap_or_default()) 669 | .insert("songid", json!(id)) 670 | .build(); 671 | 672 | self.client.request(r).await 673 | } 674 | 675 | /// 说明 : 调用此接口 , 传入音乐 id(支持多个 id, 用 , 隔开), 可获得歌曲详情 676 | /// 677 | /// requried 678 | /// 必选参数 : ids: 音乐 id, 如 ids=347230 679 | pub async fn song_detail(&self, ids: &[usize]) -> TResult { 680 | let list = ids 681 | .iter() 682 | .map(|id| json!({ "id": id }).to_string()) 683 | .collect::>(); 684 | let r = ApiRequestBuilder::post(API_ROUTE["song_detail"]) 685 | .set_data(json!({ "c": list })) 686 | .build(); 687 | 688 | self.client.request(r).await 689 | } 690 | 691 | /// 说明 : 使用歌单详情接口后 , 能得到的音乐的 id, 但不能得到的音乐 url, 调用此接口, 传入的音乐 id( 可多个 , 用逗号隔开 ), 692 | /// 可以获取对应的音乐的 url,未登录状态或者非会员返回试听片段(返回字段包含被截取的正常歌曲的开始时间和结束时间) 693 | /// 694 | /// required 695 | /// 必选参数 : id : 音乐 id 696 | /// 697 | /// optional 698 | /// 可选参数 : br: 码率,默认设置了 999000 即最大码率,如果要 320k 则可设置为 320000,其他类推 699 | pub async fn song_url(&self, ids: &Vec) -> TResult { 700 | let mut rb = ApiRequestBuilder::post(API_ROUTE["song_url"]) 701 | .set_crypto(crate::crypto::Crypto::Eapi) 702 | .add_cookie("os", "pc") 703 | .set_api_url("/api/song/enhance/player/url") 704 | .set_data(json!({"ids": ids, "br": 999000})); 705 | 706 | if self 707 | .client 708 | .cookie("MUSIC_U", self.client.base_url()) 709 | .is_none() 710 | { 711 | let mut rng = rand::thread_rng(); 712 | let mut token = [0u8; 16]; 713 | rng.fill_bytes(&mut token); 714 | rb = rb.add_cookie("_ntes_nuid", &hex::encode(token)); 715 | } 716 | 717 | self.client.request(rb.build()).await 718 | } 719 | 720 | /// 说明 : 登录后调用此接口 ,可获取用户账号信息 721 | pub async fn user_account(&self) -> TResult { 722 | let r = ApiRequestBuilder::post(API_ROUTE["user_account"]).build(); 723 | self.client.request(r).await 724 | } 725 | 726 | /// 说明 : 登录后调用此接口 , 传入云盘歌曲 id,可获取云盘数据详情 727 | /// 728 | /// requried 729 | /// 必选参数 : id: 歌曲id,可多个,用逗号隔开 730 | pub async fn user_cloud_detail(&self, ids: &Vec) -> TResult { 731 | let r = ApiRequestBuilder::post(API_ROUTE["user_cloud_detail"]) 732 | .set_data(json!({ "songIds": ids })) 733 | .build(); 734 | self.client.request(r).await 735 | } 736 | 737 | /// 说明 : 登录后调用此接口 , 可获取云盘数据 , 获取的数据没有对应 url, 需要再调用一 次 /song/url 获取 url 738 | /// 739 | /// optional 740 | /// 可选参数 : 741 | /// limit : 返回数量 , 默认为 200 742 | /// offset : 偏移数量,用于分页 , 如 :( 页数 -1)*200, 其中 200 为 limit 的值 , 默认为 0 743 | pub async fn user_cloud(&self, opt: Option) -> TResult { 744 | let r = ApiRequestBuilder::post(API_ROUTE["user_cloud"]) 745 | .set_data(limit_offset(30, 0)) 746 | .merge(opt.unwrap_or_default()) 747 | .build(); 748 | self.client.request(r).await 749 | } 750 | 751 | /// 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户历史评论 752 | /// 753 | /// requried 754 | /// 必选参数 : uid : 用户 id 755 | /// 756 | /// optional 757 | /// 可选参数 : 758 | /// limit : 返回数量 , 默认为 10 759 | /// time: 上一条数据的time,第一页不需要传,默认为0 760 | pub async fn user_comment_history( 761 | &self, 762 | uid: usize, 763 | opt: Option, 764 | ) -> TResult { 765 | let r = ApiRequestBuilder::post(API_ROUTE["user_comment_history"]) 766 | .set_data(json!({ 767 | "compose_reminder": true, 768 | "compose_hot_comment": true, 769 | "limit": 10, 770 | "time": 0, 771 | "user_id": uid, 772 | })) 773 | .merge(opt.unwrap_or_default()) 774 | .build(); 775 | self.client.request(r).await 776 | } 777 | 778 | /// 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情 779 | /// 780 | /// required 781 | /// 必选参数 : uid : 用户 id 782 | pub async fn user_detail(&self, uid: usize) -> TResult { 783 | let u = replace_all_route_params(API_ROUTE["user_detail"], &uid.to_string()); 784 | let r = ApiRequestBuilder::post(&u).build(); 785 | self.client.request(r).await 786 | } 787 | 788 | /// 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户电台 789 | /// 790 | /// required 791 | /// 必选参数 : uid : 用户 id 792 | pub async fn user_dj(&self, uid: usize, opt: Option) -> TResult { 793 | let u = replace_all_route_params(API_ROUTE["user_dj"], &uid.to_string()); 794 | let r = ApiRequestBuilder::post(&u) 795 | .set_data(limit_offset(30, 0)) 796 | .merge(opt.unwrap_or_default()) 797 | .build(); 798 | self.client.request(r).await 799 | } 800 | 801 | /// 说明 : 调用此接口, 传入用户id可获取用户创建的电台 802 | /// 803 | /// required 804 | /// 必选参数 : uid : 用户 id 805 | pub async fn user_podcast(&self, uid: usize) -> TResult { 806 | let r = ApiRequestBuilder::post(API_ROUTE["user_audio"]) 807 | .set_data(json!({ "userId": uid })) 808 | .build(); 809 | self.client.request(r).await 810 | } 811 | 812 | /// 说明 : 登录后调用此接口 , 传入rid, 可查看对应电台的电台节目以及对应的 id, 需要 注意的是这个接口返回的 mp3Url 已经无效 , 都为 null, 但是通过调用 /song/url 这 个接口 , 传入节目 id 仍然能获取到节目音频 , 如 /song/url?id=478446370 获取代 码时间的一个节目的音频 813 | /// 必选参数 : rid: 电台 的 id 814 | /// 可选参数 : 815 | /// limit : 返回数量 , 默认为 30 816 | /// offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 817 | /// asc : 排序方式,默认为 false (新 => 老 ) 设置 true 可改为 老 => 新 818 | pub async fn podcast_audio(&self, id: usize, opt: Option) -> TResult { 819 | let r = ApiRequestBuilder::post(API_ROUTE["dj_program"]) 820 | .set_data(limit_offset(30, 0)) 821 | .merge(opt.unwrap_or_default()) 822 | .merge(json!({ 823 | "radioId": id, 824 | "asc": false, 825 | })) 826 | .build(); 827 | self.client.request(r).await 828 | } 829 | 830 | /// 说明 : 登录后调用此接口 , 可以获取用户等级信息,包含当前登录天数,听歌次数,下一等级需要的登录天数和听歌次数,当前等级进度 831 | pub async fn user_level(&self) -> TResult { 832 | let r = ApiRequestBuilder::post(API_ROUTE["user_level"]).build(); 833 | self.client.request(r).await 834 | } 835 | 836 | /// 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户歌单 837 | /// 838 | /// required 839 | /// 必选参数 : uid : 用户 id 840 | /// 841 | /// optional 842 | /// 可选参数 : 843 | /// limit : 返回数量 , 默认为 30 844 | /// offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 845 | pub async fn user_playlist(&self, uid: usize, opt: Option) -> TResult { 846 | let r = ApiRequestBuilder::post(API_ROUTE["user_playlist"]) 847 | .set_data(limit_offset(30, 0)) 848 | .merge(opt.unwrap_or_default()) 849 | .merge(json!({"includeVideo": true, "uid": uid})) 850 | .build(); 851 | self.client.request(r).await 852 | } 853 | 854 | /// 说明 : 登录后调用此接口 , 传入用户 id, 可获取用户播放记录 855 | /// 856 | /// requred 857 | /// 必选参数 : uid : 用户 id 858 | /// 859 | /// optional 860 | /// 可选参数 : type : type=1 时只返回 weekData, type=0 时返回 allData 861 | pub async fn user_record(&self, uid: usize, opt: Option) -> TResult { 862 | let r = ApiRequestBuilder::post(API_ROUTE["user_record"]) 863 | .set_data(json!({"type": 1, "uid": uid})) 864 | .merge(opt.unwrap_or_default()) 865 | .build(); 866 | self.client.request(r).await 867 | } 868 | 869 | /// 说明 : 登录后调用此接口 , 可以获取用户信息 870 | /// 获取用户信息 , 歌单,收藏,mv, dj 数量 871 | pub async fn user_subcount(&self) -> TResult { 872 | let r = ApiRequestBuilder::post(API_ROUTE["user_subcount"]).build(); 873 | self.client.request(r).await 874 | } 875 | } 876 | 877 | fn replace_all_route_params(u: &str, rep: &str) -> String { 878 | let re = regex::Regex::new(r"\$\{.*\}").unwrap(); 879 | re.replace_all(u, rep).to_string() 880 | } 881 | 882 | fn limit_offset(limit: usize, offset: usize) -> Value { 883 | json!({ 884 | "limit": limit, 885 | "offset": offset 886 | }) 887 | } 888 | 889 | /// 0: 歌曲 1: mv 2: 歌单 3: 专辑 4: 电台 5: 视频 6: 动态 890 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 891 | pub enum ResourceType { 892 | Song = 0, 893 | MV = 1, 894 | Collection = 2, 895 | Album = 3, 896 | Podcast = 4, 897 | Video = 5, 898 | Moment = 6, 899 | } 900 | 901 | /// 搜索类型;1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频, 1018:综合 902 | #[derive(Serialize_repr, Deserialize_repr, PartialEq, Eq, Debug, Copy, Clone)] 903 | #[repr(usize)] 904 | pub enum SearchType { 905 | Song = 1, 906 | Album = 10, 907 | Artist = 100, 908 | Collection = 1000, 909 | User = 1002, 910 | MV = 1004, 911 | Lyric = 1006, 912 | Podcast = 1009, 913 | Video = 1014, 914 | All = 1018, 915 | } 916 | 917 | fn map_resource_code(t: ResourceType) -> String { 918 | match t { 919 | ResourceType::Song => String::from("R_SO_4_"), 920 | ResourceType::MV => String::from("R_MV_5_"), 921 | ResourceType::Collection => String::from("A_PL_0_"), 922 | ResourceType::Album => String::from("R_AL_3_"), 923 | ResourceType::Podcast => String::from("A_DJ_1_"), 924 | ResourceType::Video => String::from("R_VI_62_"), 925 | ResourceType::Moment => String::from("A_EV_2_"), 926 | } 927 | } 928 | 929 | fn md5_hex(pt: &[u8]) -> String { 930 | hex::encode(hash(MessageDigest::md5(), pt).unwrap()) 931 | } 932 | 933 | const ANONYMOUS_TOKEN: &str = "8aae43f148f990410b9a2af38324af24e87ab9227c9265627ddd10145db744295fcd8701dc45b1ab8985e142f491516295dd965bae848761274a577a62b0fdc54a50284d1e434dcc04ca6d1a52333c9a"; 934 | 935 | #[cfg(test)] 936 | mod tests { 937 | use serde::Deserialize; 938 | use tokio::fs; 939 | 940 | use crate::NcmApi; 941 | 942 | const ALBUM_ID: usize = 34808483; 943 | const SONG_ID: usize = 32977061; 944 | const COLLECTION_ID: usize = 2484967117; 945 | const ARTIST_ID: usize = 5771; 946 | const USER_ID: usize = 49668844; 947 | 948 | #[derive(Deserialize)] 949 | struct Auth { 950 | phone: String, 951 | password: String, 952 | } 953 | 954 | #[tokio::test(flavor = "multi_thread")] 955 | async fn test_search() { 956 | let api = NcmApi::default(); 957 | let resp = api.search("mota", None).await; 958 | assert!(resp.is_ok()); 959 | let res = resp.unwrap().deserialize_to_implict(); 960 | assert_eq!(res.code, 200); 961 | } 962 | 963 | #[tokio::test(flavor = "multi_thread")] 964 | async fn test_album_sub() { 965 | let api = NcmApi::default(); 966 | let resp = api.album_sub(ALBUM_ID, 1).await; 967 | assert!(resp.is_ok()); 968 | 969 | let res = resp.unwrap().deserialize_to_implict(); 970 | assert_eq!(res.code, 200); 971 | } 972 | 973 | #[tokio::test(flavor = "multi_thread")] 974 | async fn test_album_sublist() { 975 | let api = NcmApi::default(); 976 | let resp = api.album_sublist(None).await; 977 | assert!(resp.is_ok()); 978 | 979 | let res = resp.unwrap().deserialize_to_implict(); 980 | assert_eq!(res.code, 200); 981 | } 982 | 983 | #[tokio::test(flavor = "multi_thread")] 984 | async fn test_album() { 985 | let api = NcmApi::default(); 986 | let resp = api.album(ALBUM_ID).await; 987 | assert!(resp.is_ok()); 988 | 989 | let res = resp.unwrap().deserialize_to_implict(); 990 | assert_eq!(res.code, 200); 991 | } 992 | 993 | #[tokio::test(flavor = "multi_thread")] 994 | async fn test_artist_songs() { 995 | let api = NcmApi::default(); 996 | let resp = api.artist_songs(ARTIST_ID, None).await; 997 | assert!(resp.is_ok()); 998 | 999 | let res = resp.unwrap().deserialize_to_implict(); 1000 | assert_eq!(res.code, 200); 1001 | } 1002 | 1003 | #[tokio::test(flavor = "multi_thread")] 1004 | async fn test_artist_sub() { 1005 | let api = NcmApi::default(); 1006 | let resp = api.artist_sub(ARTIST_ID, 1).await; 1007 | assert!(resp.is_ok()); 1008 | 1009 | let res = resp.unwrap().deserialize_to_implict(); 1010 | assert_eq!(res.code, 200); 1011 | } 1012 | 1013 | #[tokio::test(flavor = "multi_thread")] 1014 | async fn test_artist_sublist() { 1015 | let api = NcmApi::default(); 1016 | let resp = api.artist_sublist(None).await; 1017 | assert!(resp.is_ok()); 1018 | 1019 | let res = resp.unwrap().deserialize_to_implict(); 1020 | assert_eq!(res.code, 200); 1021 | } 1022 | 1023 | #[tokio::test(flavor = "multi_thread")] 1024 | async fn test_artist_top_song() { 1025 | let api = NcmApi::default(); 1026 | let resp = api.artist_top_song(ARTIST_ID).await; 1027 | assert!(resp.is_ok()); 1028 | 1029 | let res = resp.unwrap().deserialize_to_implict(); 1030 | assert_eq!(res.code, 200); 1031 | } 1032 | 1033 | #[tokio::test(flavor = "multi_thread")] 1034 | async fn test_check_music() { 1035 | let api = NcmApi::default(); 1036 | let resp = api.check_music(SONG_ID, None).await; 1037 | assert!(resp.is_ok()); 1038 | 1039 | let res = resp.unwrap().deserialize_to_implict(); 1040 | assert_eq!(res.code, 200); 1041 | } 1042 | 1043 | #[tokio::test(flavor = "multi_thread")] 1044 | async fn test_comment_hot() { 1045 | let api = NcmApi::default(); 1046 | let resp = api 1047 | .comment_hot(SONG_ID, crate::api::ResourceType::Song, None) 1048 | .await; 1049 | assert!(resp.is_ok()); 1050 | 1051 | let res = resp.unwrap().deserialize_to_implict(); 1052 | assert_eq!(res.code, 200); 1053 | } 1054 | 1055 | #[tokio::test(flavor = "multi_thread")] 1056 | async fn test_comment() { 1057 | let api = NcmApi::default(); 1058 | let resp = api 1059 | .comment(SONG_ID, crate::api::ResourceType::Song, 1, 1, 1, 0, true) 1060 | .await; 1061 | assert!(resp.is_ok()); 1062 | 1063 | let res = resp.unwrap().deserialize_to_implict(); 1064 | assert_eq!(res.code, 200); 1065 | } 1066 | 1067 | #[tokio::test(flavor = "multi_thread")] 1068 | async fn test_comment_create() { 1069 | let api = NcmApi::default(); 1070 | let resp = api 1071 | .comment_create(SONG_ID, crate::api::ResourceType::Song, "喜欢") 1072 | .await; 1073 | assert!(resp.is_ok()); 1074 | 1075 | let res = resp.unwrap().deserialize_to_implict(); 1076 | assert_eq!(res.code, 200); 1077 | } 1078 | 1079 | #[tokio::test(flavor = "multi_thread")] 1080 | async fn test_comment_re() {} 1081 | 1082 | #[tokio::test(flavor = "multi_thread")] 1083 | async fn test_comment_del() {} 1084 | 1085 | #[tokio::test(flavor = "multi_thread")] 1086 | async fn test_daily_signin() { 1087 | let api = NcmApi::default(); 1088 | let resp = api.daily_signin(None).await; 1089 | assert!(resp.is_ok()); 1090 | 1091 | let res = resp.unwrap().deserialize_to_implict(); 1092 | assert_eq!(res.code, 200); 1093 | } 1094 | 1095 | #[tokio::test(flavor = "multi_thread")] 1096 | async fn test_fm_trash() { 1097 | let api = NcmApi::default(); 1098 | let resp = api.fm_trash(347230).await; 1099 | assert!(resp.is_ok()); 1100 | 1101 | let res = resp.unwrap().deserialize_to_implict(); 1102 | assert_eq!(res.code, 200); 1103 | } 1104 | 1105 | #[tokio::test(flavor = "multi_thread")] 1106 | async fn test_like() { 1107 | let api = NcmApi::default(); 1108 | let resp = api.like(SONG_ID, None).await; 1109 | assert!(resp.is_ok()); 1110 | 1111 | let res = resp.unwrap().deserialize_to_implict(); 1112 | assert_eq!(res.code, 200); 1113 | } 1114 | 1115 | #[tokio::test(flavor = "multi_thread")] 1116 | async fn test_likelist() { 1117 | let api = NcmApi::default(); 1118 | let resp = api.likelist(USER_ID).await; 1119 | assert!(resp.is_ok()); 1120 | 1121 | let res = resp.unwrap().deserialize_to_implict(); 1122 | assert_eq!(res.code, 200); 1123 | } 1124 | 1125 | #[tokio::test(flavor = "multi_thread")] 1126 | async fn test_login_phone() { 1127 | let f = fs::read_to_string("test-data/auth.json") 1128 | .await 1129 | .expect("no auth file"); 1130 | let auth: Auth = serde_json::from_str(&f).unwrap(); 1131 | 1132 | let api = NcmApi::default(); 1133 | let resp = api.login_phone(&auth.phone, &auth.password).await; 1134 | assert!(resp.is_ok()); 1135 | 1136 | let res = resp.unwrap().deserialize_to_implict(); 1137 | assert_eq!(res.code, 200); 1138 | } 1139 | 1140 | #[tokio::test(flavor = "multi_thread")] 1141 | async fn test_login_refresh() { 1142 | let api = NcmApi::default(); 1143 | let resp = api.login_refresh().await; 1144 | assert!(resp.is_ok()); 1145 | 1146 | let res = resp.unwrap().deserialize_to_implict(); 1147 | assert_eq!(res.code, 200); 1148 | } 1149 | 1150 | #[tokio::test(flavor = "multi_thread")] 1151 | async fn test_login_status() { 1152 | let api = NcmApi::default(); 1153 | let resp = api.login_status().await; 1154 | assert!(resp.is_ok()); 1155 | 1156 | let res = resp.unwrap(); 1157 | let res = res.deserialize_to_implict(); 1158 | assert_eq!(res.code, 200); 1159 | } 1160 | 1161 | #[tokio::test(flavor = "multi_thread")] 1162 | async fn test_logout() { 1163 | let api = NcmApi::default(); 1164 | let resp = api.logout().await; 1165 | assert!(resp.is_ok()); 1166 | 1167 | let res = resp.unwrap(); 1168 | let res = res.deserialize_to_implict(); 1169 | assert_eq!(res.code, 200); 1170 | } 1171 | 1172 | #[tokio::test(flavor = "multi_thread")] 1173 | async fn test_lyric() { 1174 | let api = NcmApi::default(); 1175 | let resp = api.lyric(SONG_ID).await; 1176 | assert!(resp.is_ok()); 1177 | 1178 | let res = resp.unwrap(); 1179 | let res = res.deserialize_to_implict(); 1180 | assert_eq!(res.code, 200); 1181 | } 1182 | 1183 | #[tokio::test(flavor = "multi_thread")] 1184 | async fn test_personal_fm() { 1185 | let api = NcmApi::default(); 1186 | let resp = api.personal_fm().await; 1187 | assert!(resp.is_ok()); 1188 | 1189 | let res = resp.unwrap(); 1190 | let res = res.deserialize_to_implict(); 1191 | assert_eq!(res.code, 200); 1192 | } 1193 | 1194 | #[tokio::test(flavor = "multi_thread")] 1195 | async fn test_playlist_detail() { 1196 | let api = NcmApi::default(); 1197 | let resp = api.playlist_detail(COLLECTION_ID, None).await; 1198 | assert!(resp.is_ok()); 1199 | 1200 | let res = resp.unwrap(); 1201 | let res = res.deserialize_to_implict(); 1202 | assert_eq!(res.code, 200); 1203 | } 1204 | 1205 | #[tokio::test(flavor = "multi_thread")] 1206 | async fn test_playlist_tracks() {} 1207 | 1208 | #[tokio::test(flavor = "multi_thread")] 1209 | async fn test_playlist_update() {} 1210 | 1211 | #[tokio::test(flavor = "multi_thread")] 1212 | async fn test_recommend_resource() { 1213 | let api = NcmApi::default(); 1214 | let resp = api.recommend_resource().await; 1215 | assert!(resp.is_ok()); 1216 | 1217 | let res = resp.unwrap(); 1218 | let res = res.deserialize_to_implict(); 1219 | assert_eq!(res.code, 200); 1220 | } 1221 | 1222 | #[tokio::test(flavor = "multi_thread")] 1223 | async fn test_recommend_songs() { 1224 | let api = NcmApi::default(); 1225 | let resp = api.recommend_songs().await; 1226 | assert!(resp.is_ok()); 1227 | 1228 | let res = resp.unwrap(); 1229 | let res = res.deserialize_to_implict(); 1230 | assert_eq!(res.code, 200); 1231 | } 1232 | 1233 | #[tokio::test(flavor = "multi_thread")] 1234 | async fn test_scrobble() { 1235 | let api = NcmApi::default(); 1236 | let resp = api.scrobble(29106885, COLLECTION_ID).await; 1237 | assert!(resp.is_ok()); 1238 | 1239 | let res = resp.unwrap(); 1240 | let res = res.deserialize_to_implict(); 1241 | assert_eq!(res.code, 200); 1242 | } 1243 | 1244 | #[tokio::test(flavor = "multi_thread")] 1245 | async fn test_search_default() { 1246 | let api = NcmApi::default(); 1247 | let resp = api.search_default().await; 1248 | assert!(resp.is_ok()); 1249 | 1250 | let res = resp.unwrap(); 1251 | let res = res.deserialize_to_implict(); 1252 | assert_eq!(res.code, 200); 1253 | } 1254 | 1255 | #[tokio::test(flavor = "multi_thread")] 1256 | async fn test_search_hot_detail() { 1257 | let api = NcmApi::default(); 1258 | let resp = api.search_hot_detail().await; 1259 | assert!(resp.is_ok()); 1260 | 1261 | let res = resp.unwrap(); 1262 | let res = res.deserialize_to_implict(); 1263 | assert_eq!(res.code, 200); 1264 | } 1265 | 1266 | #[tokio::test(flavor = "multi_thread")] 1267 | async fn test_search_hot() { 1268 | let api = NcmApi::default(); 1269 | let resp = api.search_hot().await; 1270 | assert!(resp.is_ok()); 1271 | 1272 | let res = resp.unwrap(); 1273 | let res = res.deserialize_to_implict(); 1274 | assert_eq!(res.code, 200); 1275 | } 1276 | 1277 | #[tokio::test(flavor = "multi_thread")] 1278 | async fn test_search_suggest() { 1279 | let api = NcmApi::default(); 1280 | let resp = api.search_suggest("mota", None).await; 1281 | assert!(resp.is_ok()); 1282 | 1283 | let res = resp.unwrap(); 1284 | let res = res.deserialize_to_implict(); 1285 | assert_eq!(res.code, 200); 1286 | } 1287 | 1288 | #[tokio::test(flavor = "multi_thread")] 1289 | async fn test_simi_artist() { 1290 | let api = NcmApi::default(); 1291 | let resp = api.simi_artist(ARTIST_ID).await; 1292 | assert!(resp.is_ok()); 1293 | 1294 | let res = resp.unwrap(); 1295 | let res = res.deserialize_to_implict(); 1296 | assert_eq!(res.code, 200); 1297 | } 1298 | 1299 | #[tokio::test(flavor = "multi_thread")] 1300 | async fn test_simi_playlist() { 1301 | let api = NcmApi::default(); 1302 | let resp = api.simi_playlist(SONG_ID, None).await; 1303 | assert!(resp.is_ok()); 1304 | 1305 | let res = resp.unwrap(); 1306 | let res = res.deserialize_to_implict(); 1307 | assert_eq!(res.code, 200); 1308 | } 1309 | 1310 | #[tokio::test(flavor = "multi_thread")] 1311 | async fn test_simi_song() { 1312 | let api = NcmApi::default(); 1313 | let resp = api.simi_song(SONG_ID, None).await; 1314 | assert!(resp.is_ok()); 1315 | 1316 | let res = resp.unwrap(); 1317 | let res = res.deserialize_to_implict(); 1318 | assert_eq!(res.code, 200); 1319 | } 1320 | 1321 | #[tokio::test(flavor = "multi_thread")] 1322 | async fn test_song_detail() { 1323 | let api = NcmApi::default(); 1324 | let resp = api.song_detail(&[SONG_ID]).await; 1325 | assert!(resp.is_ok()); 1326 | 1327 | let res = resp.unwrap(); 1328 | let res = res.deserialize_to_implict(); 1329 | assert_eq!(res.code, 200); 1330 | } 1331 | 1332 | #[tokio::test(flavor = "multi_thread")] 1333 | async fn test_song_url() { 1334 | let api = NcmApi::default(); 1335 | let resp = api.song_url(&vec![SONG_ID]).await; 1336 | assert!(resp.is_ok()); 1337 | 1338 | let res = resp.unwrap(); 1339 | let res = res.deserialize_to_implict(); 1340 | assert_eq!(res.code, 200); 1341 | } 1342 | 1343 | #[tokio::test(flavor = "multi_thread")] 1344 | async fn test_user_account() { 1345 | let api = NcmApi::default(); 1346 | let resp = api.user_account().await; 1347 | assert!(resp.is_ok()); 1348 | 1349 | let res = resp.unwrap(); 1350 | let res = res.deserialize_to_implict(); 1351 | assert_eq!(res.code, 200); 1352 | } 1353 | 1354 | #[tokio::test(flavor = "multi_thread")] 1355 | async fn test_user_cloud_detail() { 1356 | // let api = NcmApi::default(); 1357 | // let resp = api.user_cloud_detail().await; 1358 | // assert!(resp.is_ok()); 1359 | 1360 | // let res = resp.unwrap(); 1361 | // let res = res.deserialize_to_implict(); 1362 | // assert_eq!(res.code, 200); 1363 | } 1364 | 1365 | #[tokio::test(flavor = "multi_thread")] 1366 | async fn test_user_cloud() { 1367 | let api = NcmApi::default(); 1368 | let resp = api.user_cloud(None).await; 1369 | assert!(resp.is_ok()); 1370 | 1371 | let res = resp.unwrap(); 1372 | let res = res.deserialize_to_implict(); 1373 | assert_eq!(res.code, 200); 1374 | } 1375 | 1376 | #[tokio::test(flavor = "multi_thread")] 1377 | async fn test_user_comment_history() { 1378 | let api = NcmApi::default(); 1379 | let resp = api.user_comment_history(USER_ID, None).await; 1380 | assert!(resp.is_ok()); 1381 | 1382 | let res = resp.unwrap(); 1383 | let res = res.deserialize_to_implict(); 1384 | assert_eq!(res.code, 200); 1385 | } 1386 | 1387 | #[tokio::test(flavor = "multi_thread")] 1388 | async fn test_user_detail() { 1389 | let api = NcmApi::default(); 1390 | let resp = api.user_detail(USER_ID).await; 1391 | assert!(resp.is_ok()); 1392 | 1393 | let res = resp.unwrap(); 1394 | let res = res.deserialize_to_implict(); 1395 | assert_eq!(res.code, 200); 1396 | } 1397 | 1398 | #[tokio::test(flavor = "multi_thread")] 1399 | async fn test_user_dj() { 1400 | let api = NcmApi::default(); 1401 | let resp = api.user_dj(USER_ID, None).await; 1402 | assert!(resp.is_ok()); 1403 | 1404 | let res = resp.unwrap(); 1405 | let res = res.deserialize_to_implict(); 1406 | assert_eq!(res.code, 200); 1407 | } 1408 | 1409 | #[tokio::test(flavor = "multi_thread")] 1410 | async fn test_user_podcast() { 1411 | let api = NcmApi::default(); 1412 | let resp = api.user_podcast(USER_ID).await; 1413 | assert!(resp.is_ok()); 1414 | 1415 | let res = resp.unwrap(); 1416 | let res = res.deserialize_to_implict(); 1417 | assert_eq!(res.code, 200); 1418 | } 1419 | 1420 | #[tokio::test(flavor = "multi_thread")] 1421 | async fn test_podcast_audio() { 1422 | let api = NcmApi::default(); 1423 | let resp = api.podcast_audio(965114264, None).await; 1424 | assert!(resp.is_ok()); 1425 | 1426 | let res = resp.unwrap(); 1427 | let res = res.deserialize_to_implict(); 1428 | assert_eq!(res.code, 200); 1429 | } 1430 | 1431 | #[tokio::test(flavor = "multi_thread")] 1432 | async fn test_user_level() { 1433 | let api = NcmApi::default(); 1434 | let resp = api.user_level().await; 1435 | assert!(resp.is_ok()); 1436 | 1437 | let res = resp.unwrap(); 1438 | let res = res.deserialize_to_implict(); 1439 | assert_eq!(res.code, 200); 1440 | } 1441 | 1442 | #[tokio::test(flavor = "multi_thread")] 1443 | async fn test_user_playlist() { 1444 | let api = NcmApi::default(); 1445 | let resp = api.user_playlist(USER_ID, None).await; 1446 | assert!(resp.is_ok()); 1447 | 1448 | let res = resp.unwrap(); 1449 | let res = res.deserialize_to_implict(); 1450 | assert_eq!(res.code, 200); 1451 | } 1452 | 1453 | #[tokio::test(flavor = "multi_thread")] 1454 | async fn test_user_record() { 1455 | let api = NcmApi::default(); 1456 | let resp = api.user_record(USER_ID, None).await; 1457 | assert!(resp.is_ok()); 1458 | 1459 | let res = resp.unwrap(); 1460 | let res = res.deserialize_to_implict(); 1461 | assert_eq!(res.code, 200); 1462 | } 1463 | 1464 | #[tokio::test(flavor = "multi_thread")] 1465 | async fn test_user_subcount() { 1466 | let api = NcmApi::default(); 1467 | let resp = api.user_subcount().await; 1468 | assert!(resp.is_ok()); 1469 | 1470 | let res = resp.unwrap(); 1471 | let res = res.deserialize_to_implict(); 1472 | assert_eq!(res.code, 200); 1473 | } 1474 | } 1475 | -------------------------------------------------------------------------------- /src/client/api_request.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::UA; 4 | use crate::crypto::Crypto; 5 | use openssl::hash::{hash, MessageDigest}; 6 | use serde::Serialize; 7 | use serde_json::{json, Value}; 8 | 9 | #[derive(Serialize, Debug)] 10 | pub struct ApiRequest { 11 | method: Method, 12 | url: String, 13 | data: Option, 14 | option: RequestOption, 15 | } 16 | 17 | pub struct ApiRequestBuilder { 18 | config: Config, 19 | } 20 | 21 | struct Config { 22 | method: Method, 23 | url: String, 24 | data: Option, 25 | // options 26 | ua: UA, 27 | cookies: Option, 28 | crypto: Crypto, 29 | api_url: Option, 30 | real_ip: Option, 31 | } 32 | 33 | type Pieces = ( 34 | Method, 35 | String, 36 | Option, 37 | UA, 38 | Option, 39 | Crypto, 40 | Option, 41 | Option, 42 | ); 43 | 44 | impl Default for ApiRequestBuilder { 45 | fn default() -> Self { 46 | ApiRequestBuilder::new(Method::Post, "") 47 | } 48 | } 49 | 50 | impl ApiRequestBuilder { 51 | pub fn new(method: Method, url: &str) -> Self { 52 | Self { 53 | config: Config { 54 | method, 55 | url: url.to_owned(), 56 | data: None, 57 | ua: UA::Chrome, 58 | cookies: None, 59 | crypto: Crypto::Weapi, 60 | api_url: None, 61 | real_ip: None, 62 | }, 63 | } 64 | } 65 | 66 | pub fn build(self) -> ApiRequest { 67 | let (method, url, data, ua, cookies, crypto, api_url, real_ip) = self.pieces(); 68 | ApiRequest { 69 | method, 70 | url, 71 | data, 72 | option: RequestOption { 73 | ua, 74 | cookies, 75 | crypto, 76 | api_url, 77 | real_ip, 78 | }, 79 | } 80 | } 81 | 82 | pub fn post(url: &str) -> Self { 83 | Self::new(Method::Post, url) 84 | } 85 | 86 | pub fn pieces(self) -> Pieces { 87 | let config = self.config; 88 | ( 89 | config.method, 90 | config.url, 91 | config.data, 92 | config.ua, 93 | config.cookies, 94 | config.crypto, 95 | config.api_url, 96 | config.real_ip, 97 | ) 98 | } 99 | 100 | // pub fn set_method(mut self, method: Method) -> Self { 101 | // self.config.method = method; 102 | // self 103 | // } 104 | 105 | // pub fn set_url(mut self, url: &str) -> Self { 106 | // self.config.url = String::from(url); 107 | // self 108 | // } 109 | 110 | pub fn set_data(mut self, data: H) -> Self { 111 | self.config.data = Some(data); 112 | self 113 | } 114 | 115 | // data mutaion 116 | pub fn insert(mut self, key: &str, val: Value) -> Self { 117 | let mut data = self.config.data.unwrap_or_else(|| json!({})); 118 | 119 | data.as_object_mut().unwrap().insert(key.to_owned(), val); 120 | self.config.data = Some(data); 121 | self 122 | } 123 | 124 | pub fn merge(mut self, val: Value) -> Self { 125 | if !val.is_object() { 126 | return self; 127 | } 128 | 129 | let mut data = self.config.data.unwrap_or_else(|| json!({})); 130 | for (k, v) in val.as_object().unwrap() { 131 | data.as_object_mut() 132 | .unwrap() 133 | .insert(k.to_owned(), v.to_owned()); 134 | } 135 | self.config.data = Some(data); 136 | self 137 | } 138 | 139 | pub fn set_ua(mut self, ua: UA) -> Self { 140 | self.config.ua = ua; 141 | self 142 | } 143 | 144 | #[allow(unused)] 145 | pub fn set_cookies(mut self, cookies: Hm) -> Self { 146 | self.config.cookies = Some(cookies); 147 | self 148 | } 149 | 150 | pub fn add_cookie(mut self, name: &str, val: &str) -> Self { 151 | let mut cookies = self.config.cookies.unwrap_or_default(); 152 | cookies.insert(name.to_owned(), val.to_owned()); 153 | 154 | self.config.cookies = Some(cookies); 155 | self 156 | } 157 | 158 | pub fn set_crypto(mut self, crypto: Crypto) -> Self { 159 | self.config.crypto = crypto; 160 | self 161 | } 162 | 163 | pub fn set_api_url(mut self, u: &str) -> Self { 164 | self.config.api_url = Some(String::from(u)); 165 | self 166 | } 167 | 168 | pub fn set_real_ip(mut self, real_ip: &str) -> Self { 169 | self.config.real_ip = Some(String::from(real_ip)); 170 | self 171 | } 172 | } 173 | 174 | impl Default for ApiRequest { 175 | fn default() -> Self { 176 | ApiRequest::new(Method::Post, "") 177 | } 178 | } 179 | 180 | impl ApiRequest { 181 | pub fn new(method: Method, url: &str) -> Self { 182 | ApiRequestBuilder::new(method, url).build() 183 | } 184 | 185 | // tear down 186 | pub fn pieces(self) -> Pieces { 187 | ( 188 | self.method, 189 | self.url, 190 | self.data, 191 | self.option.ua, 192 | self.option.cookies, 193 | self.option.crypto, 194 | self.option.api_url, 195 | self.option.real_ip, 196 | ) 197 | } 198 | 199 | fn serialize(&self) -> String { 200 | serde_json::to_string(self).unwrap() 201 | } 202 | 203 | pub fn id(&self) -> String { 204 | let digest = hash(MessageDigest::md5(), self.serialize().as_bytes()).unwrap(); 205 | hex::encode(digest) 206 | } 207 | 208 | // pub fn url(&self) -> &str { 209 | // &self.url 210 | // } 211 | 212 | // pub fn data(&self) -> Option<&H> { 213 | // self.data.as_ref() 214 | // } 215 | 216 | // pub fn ua(&self) -> &UA { 217 | // &self.option.ua 218 | // } 219 | 220 | // pub fn cookies(&self) -> Option<&Hm> { 221 | // self.option.cookies.as_ref() 222 | // } 223 | 224 | // pub fn crypto(&self) -> &Crypto { 225 | // &self.option.crypto 226 | // } 227 | 228 | // pub fn api_url(&self) -> Option<&String> { 229 | // self.option.api_url.as_ref() 230 | // } 231 | 232 | // pub fn real_ip(&self) -> Option<&String> { 233 | // self.option.real_ip.as_ref() 234 | // } 235 | 236 | // pub fn option(&self) -> &RequestOption { 237 | // &self.option 238 | // } 239 | } 240 | 241 | #[derive(Serialize, Debug)] 242 | pub struct RequestOption { 243 | ua: UA, 244 | cookies: Option, 245 | crypto: Crypto, 246 | api_url: Option, 247 | real_ip: Option, 248 | } 249 | 250 | #[derive(Serialize, Debug, PartialEq, Eq, Clone, Copy)] 251 | #[allow(unused)] 252 | pub enum Method { 253 | Get, 254 | Head, 255 | Post, 256 | Put, 257 | Delete, 258 | Connect, 259 | Options, 260 | Trace, 261 | Patch, 262 | } 263 | 264 | pub(crate) type Hm = HashMap; 265 | pub(crate) type H = Value; 266 | // pub(crate) type H = Map; 267 | 268 | #[cfg(test)] 269 | mod tests { 270 | use serde_json::json; 271 | 272 | use crate::client::api_request::Hm; 273 | use crate::client::route::API_ROUTE; 274 | use crate::client::ApiRequestBuilder; 275 | use crate::client::UA; 276 | 277 | type Rb = ApiRequestBuilder; 278 | 279 | #[test] 280 | fn test_request_builder() { 281 | let r = Rb::post(API_ROUTE["search"]) 282 | .set_data(json!({ 283 | "name": "alex", 284 | })) 285 | .insert("age", json!(19)) 286 | .merge(json!({ 287 | "books": ["book1", "book2"] 288 | })) 289 | .set_api_url("/api/url") 290 | .set_real_ip("real_ip") 291 | .set_ua(UA::IPhone) 292 | .set_cookies(Hm::new()) 293 | .add_cookie("sid", "f1h82fg191fh9") 294 | .build(); 295 | 296 | assert_eq!(r.data.unwrap()["age"], 19); 297 | } 298 | 299 | #[test] 300 | fn test_serialize() { 301 | let r = Rb::post(API_ROUTE["search"]) 302 | .set_data(json!({ 303 | "name": "alex", 304 | })) 305 | .build(); 306 | 307 | let a = r.serialize(); 308 | println!("{}", a) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/client/api_response.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | use std::fmt::{self, Display}; 4 | 5 | pub struct ApiResponse { 6 | data: Vec, 7 | } 8 | 9 | 10 | impl Display for ApiResponse { 11 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 12 | write!(f, "{}", String::from_utf8_lossy(self.data())) 13 | } 14 | } 15 | 16 | impl ApiResponse { 17 | pub fn new(data: Vec) -> Self { 18 | Self { data } 19 | } 20 | 21 | pub fn data(&self) -> &Vec { 22 | &self.data 23 | } 24 | 25 | pub fn deserialize_to_implict(&self) -> ImplicitResult { 26 | serde_json::from_slice::(self.data()).unwrap() 27 | } 28 | 29 | pub fn deserialize<'a, T>(&'a self) -> Result 30 | where 31 | T: Serialize + Deserialize<'a>, 32 | { 33 | serde_json::from_slice::(self.data()) 34 | } 35 | } 36 | 37 | impl fmt::Debug for ApiResponse { 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 39 | f.debug_struct("ApiResponse") 40 | .field("data", &self.to_string()) 41 | .finish() 42 | } 43 | } 44 | 45 | #[derive(Serialize, Deserialize, Debug)] 46 | pub struct ImplicitResult { 47 | #[serde(default)] 48 | pub code: usize, 49 | 50 | #[serde(default)] 51 | pub msg: Value, 52 | 53 | #[serde(default)] 54 | pub message: Value, 55 | 56 | #[serde(default)] 57 | pub time: usize, 58 | 59 | #[serde(default)] 60 | pub result: Value, 61 | 62 | #[serde(default)] 63 | pub data: Value, 64 | } 65 | -------------------------------------------------------------------------------- /src/client/mod.rs: -------------------------------------------------------------------------------- 1 | mod api_request; 2 | mod api_response; 3 | mod route; 4 | mod store; 5 | 6 | use std::{ 7 | borrow::Cow, 8 | convert::TryFrom, 9 | fs::{File, OpenOptions}, 10 | io::{Read, Write}, 11 | path::Path, 12 | sync::Arc, 13 | time::{Duration, SystemTime, UNIX_EPOCH}, 14 | }; 15 | 16 | use cookie::Cookie; 17 | use rand::Rng; 18 | use regex::Regex; 19 | use reqwest::{ 20 | cookie::{CookieStore, Jar}, 21 | header::{HeaderMap, HeaderValue, CONTENT_TYPE, COOKIE, REFERER, SET_COOKIE, USER_AGENT}, 22 | Client, Request, Response, Url, 23 | }; 24 | use serde::Serialize; 25 | 26 | pub use api_request::{ApiRequest, ApiRequestBuilder}; 27 | pub use api_response::ApiResponse; 28 | pub(crate) use route::API_ROUTE; 29 | use serde_json::{json, Value}; 30 | use store::{InMemStore, Store}; 31 | 32 | use crate::TResult; 33 | use crate::{ 34 | crypto::{eapi, linuxapi, weapi, Crypto}, 35 | ApiErr, 36 | }; 37 | 38 | use self::api_request::Hm; 39 | 40 | pub struct ApiClient { 41 | config: Config, 42 | client: Client, 43 | store: Box, 44 | // this is a compromise way to sync & retrive cookies, since access to cookie jar 45 | // is denied by self.client::Afc.cookie_store; 46 | jar: Arc, 47 | } 48 | 49 | impl Default for ApiClient { 50 | fn default() -> Self { 51 | let cookie_path = "/var/tmp/ncmapi_client_cookies"; 52 | Self::new(cookie_path) 53 | } 54 | } 55 | 56 | #[derive(Debug)] 57 | pub struct ApiClientBuilder { 58 | config: Config, 59 | } 60 | 61 | impl ApiClientBuilder { 62 | pub fn new(cookie_path: &str) -> Self { 63 | ApiClientBuilder { 64 | config: Config { 65 | cache: true, 66 | cache_exp: Duration::from_secs(3 * 60), 67 | cache_clean_interval: Duration::from_secs(6 * 60), 68 | base_url: BASE_URL.parse::().unwrap(), 69 | preserve_cookies: true, 70 | cookie_path: String::from(cookie_path), 71 | log_request: false, 72 | log_response: false, 73 | }, 74 | } 75 | } 76 | 77 | pub fn build(self) -> TResult { 78 | let config = self.config; 79 | let ci = config.cache_clean_interval; 80 | let jar = Arc::new(Jar::default()); 81 | 82 | // sync cookies 83 | if let Ok(cs) = read_cookies(&config.cookie_path) { 84 | if !cs.is_empty() { 85 | let ch = cs 86 | .split("; ") 87 | .map(|cookie| HeaderValue::from_str(cookie).unwrap()) 88 | .collect::>(); 89 | // let mut cs = ch.iter().map(|c| c); 90 | jar.set_cookies(&mut ch.iter(), &config.base_url); 91 | } 92 | } 93 | 94 | Ok(ApiClient { 95 | config, 96 | client: Client::builder().cookie_store(false).build().unwrap(), 97 | store: Box::new(Store::new(ci)), 98 | jar, 99 | }) 100 | } 101 | 102 | pub fn cache(mut self, enable: bool) -> Self { 103 | self.config.cache = enable; 104 | self 105 | } 106 | 107 | pub fn cache_exp(mut self, exp: Duration) -> Self { 108 | self.config.cache_exp = exp; 109 | self 110 | } 111 | 112 | pub fn cache_clean_interval(mut self, exp: Duration) -> Self { 113 | self.config.cache_clean_interval = exp; 114 | self 115 | } 116 | 117 | pub fn preserve_cookies(mut self, enable: bool) -> Self { 118 | self.config.preserve_cookies = enable; 119 | self 120 | } 121 | 122 | #[allow(unused)] 123 | pub fn log_request(mut self, enable: bool) -> Self { 124 | self.config.log_request = enable; 125 | self 126 | } 127 | 128 | #[allow(unused)] 129 | pub fn log_response(mut self, enable: bool) -> Self { 130 | self.config.log_response = enable; 131 | self 132 | } 133 | 134 | pub fn cookie_path(mut self, path: &str) -> Self { 135 | self.config.cookie_path = path.to_owned(); 136 | self 137 | } 138 | } 139 | 140 | impl ApiClient { 141 | /// cookie_path: file path of cookie cache 142 | pub fn new(cookie_path: &str) -> ApiClient { 143 | ApiClientBuilder::new(cookie_path) 144 | .build() 145 | .expect("build apiclient fail") 146 | } 147 | 148 | pub async fn request(&self, req: ApiRequest) -> TResult { 149 | let id = req.id(); 150 | 151 | if self.store.contains_key(&id) { 152 | return Ok(self.store.get(&id).unwrap()); 153 | } 154 | 155 | let request = self.to_http_request(req)?; 156 | if self.config.log_request { 157 | println!("{:#?}", request); 158 | } 159 | 160 | let resp = self 161 | .client 162 | .execute(request) 163 | .await 164 | .map_err(|_| ApiErr::ReqwestErr)?; 165 | self.on_response(id, resp).await 166 | } 167 | 168 | async fn on_response(&self, id: String, resp: Response) -> TResult { 169 | let mut cs = resp.headers().get_all(SET_COOKIE).iter().peekable(); 170 | if cs.peek().is_some() { 171 | // sync cookie to jar 172 | self.jar.set_cookies(&mut cs, resp.url()); 173 | // sync cookie to local 174 | let hv = self.jar.cookies(&self.config.base_url).unwrap(); 175 | write_cookies(&self.config.cookie_path, hv.to_str().unwrap()).unwrap_or_default(); 176 | } 177 | 178 | let body = resp.bytes().await.map_err(|_| ApiErr::ReqwestErr)?; 179 | let res = ApiResponse::new(body.to_vec()); 180 | 181 | // cache response 182 | self.store 183 | .insert(id.clone(), res, Some(self.config.cache_exp)); 184 | 185 | Ok(self.store.get(&id).unwrap()) 186 | } 187 | 188 | fn to_http_request(&self, req: ApiRequest) -> TResult { 189 | let (method, url, data, ua, cookies, crypto, api_url, real_ip) = req.pieces(); 190 | // unwrap or else is lazily evaluated. 191 | let mut data = data.unwrap_or_else(|| json!({})); 192 | 193 | // basic header 194 | let mut headers = HeaderMap::new(); 195 | headers.insert(USER_AGENT, HeaderValue::from_static(fake_ua(ua))); 196 | if method == api_request::Method::Post { 197 | headers.insert( 198 | CONTENT_TYPE, 199 | HeaderValue::from_static("application/x-www-form-urlencoded"), 200 | ); 201 | } 202 | if url.contains("music.163.com") { 203 | headers.insert(REFERER, HeaderValue::from_static(BASE_URL)); 204 | } 205 | if let Some(real_ip) = real_ip { 206 | headers.insert("X-Real-IP", HeaderValue::try_from(real_ip).unwrap()); 207 | } 208 | 209 | // COOKIE header might be overrided by the cookie_store according to 210 | // reqwest/async_impl/client.rs line: 1232 of version: e6a1a09f0904e06de4ff1317278798c4ed28af66 211 | // 212 | // The leading dot means that the cookie is valid for subdomains as well; 213 | // nevertheless recent HTTP specifications (RFC 6265) changed this rule so modern browsers 214 | // should notcare about the leading dot. The dot may be needed by old browser implementing the deprecated RFC 2109. 215 | // 216 | // so what's sense of adding cookie to the request header which will will be overrided. 217 | // Another mechanism of preserving cookies might be required. // TODO --> Solved by disable cookies_store & build a new one 218 | // by simulating. 219 | match crypto { 220 | // option cookies + jar cookies 221 | Crypto::Weapi => { 222 | let mut cs = String::new(); 223 | // jar cookies 224 | let jc = self.jar.cookies(self.base_url()); 225 | if let Some(hv) = jc { 226 | cs.push_str(hv.to_str().unwrap()); 227 | } 228 | 229 | // option cookies 230 | if let Some(oc) = &cookies { 231 | let oc = oc 232 | .iter() 233 | .map(|(k, v)| format!("{}={}", k, v)) 234 | .collect::>() 235 | .join("; "); 236 | cs.push_str("; "); 237 | cs.push_str(&oc); 238 | } 239 | headers.insert(COOKIE, HeaderValue::try_from(cs).unwrap()); 240 | } 241 | Crypto::Eapi => { 242 | let mut cs = self.eapi_header_cookies(); 243 | if let Some(ref cookies) = cookies { 244 | for (k, v) in cookies { 245 | cs.insert(k.to_owned(), v.to_owned()); 246 | } 247 | } 248 | 249 | let cs = cs 250 | .iter() 251 | .map(|(k, v)| format!("{}={}", k, v)) 252 | .collect::>() 253 | .join("; "); 254 | headers.insert(COOKIE, HeaderValue::try_from(cs).unwrap()); 255 | } 256 | Crypto::Linuxapi => { 257 | let cs = self 258 | .jar 259 | .cookies(self.base_url()) 260 | .unwrap_or(HeaderValue::from_static("")); 261 | headers.insert(COOKIE, HeaderValue::try_from(cs.to_str().unwrap()).unwrap()); 262 | } 263 | } 264 | 265 | // payload 266 | // form data 267 | match crypto { 268 | Crypto::Weapi => { 269 | let key = "csrf_token"; 270 | let mut val = String::new(); 271 | if let Some(cookie) = self.cookie("__csrf", &self.config.base_url) { 272 | val = cookie.value().to_owned(); 273 | } 274 | data.as_object_mut() 275 | .unwrap() 276 | .insert(key.to_owned(), Value::String(val)); 277 | } 278 | Crypto::Eapi => { 279 | let mut cs = self.eapi_header_cookies(); 280 | if let Some(ref cookies) = cookies { 281 | for (k, v) in cookies { 282 | cs.insert(k.to_owned(), v.to_owned()); 283 | } 284 | } 285 | data.as_object_mut() 286 | .unwrap() 287 | .insert("header".to_owned(), json!(cs)); 288 | } 289 | Crypto::Linuxapi => {} 290 | } 291 | 292 | let form_data = { 293 | match crypto { 294 | Crypto::Weapi => { 295 | let data = data.to_string(); 296 | weapi(data.as_bytes()).into_vec() 297 | } 298 | Crypto::Eapi => { 299 | let data = data.to_string(); 300 | let api_url = api_url.unwrap(); 301 | eapi(api_url.as_bytes(), data.as_bytes()).into_vec() 302 | } 303 | Crypto::Linuxapi => { 304 | let data = json!({ 305 | "method": map_method(method).to_string(), 306 | "url": adapt_url(&url, crypto), 307 | "params": &data, 308 | }) 309 | .to_string(); 310 | linuxapi(data.as_bytes()).into_vec() 311 | } 312 | } 313 | }; 314 | 315 | // request builder 316 | let rb = self 317 | .client 318 | .request( 319 | map_method(method), 320 | adapt_url(&url, crypto) 321 | .parse::() 322 | .map_err(|_| ApiErr::ParseUrlErr)?, 323 | ) 324 | .headers(headers) 325 | .form(&form_data); 326 | 327 | rb.build().map_err(|_| ApiErr::ReqwestErr) 328 | } 329 | 330 | fn cookies(&self, url: &Url) -> Vec { 331 | let mut cs = Vec::new(); 332 | if let Some(cookies) = self.jar.cookies(url) { 333 | if !cookies.is_empty() { 334 | cookies 335 | .to_str() 336 | .unwrap() 337 | .split(';') 338 | .map(|s| Cookie::parse(s.to_owned()).unwrap()) 339 | .for_each(|c| cs.push(c)); 340 | } 341 | } 342 | cs 343 | } 344 | 345 | pub fn base_url(&self) -> &Url { 346 | &self.config.base_url 347 | } 348 | 349 | pub fn cookie(&self, name: &str, url: &Url) -> Option { 350 | for c in self.cookies(url) { 351 | if c.name() == name { 352 | return Some(c); 353 | } 354 | } 355 | None 356 | } 357 | 358 | fn cookie_netease_eapi(&self, name: &str) -> Option { 359 | if let Some(cookie) = self.cookie(name, &self.config.base_url) { 360 | return Some(cookie.value().to_owned()); 361 | } 362 | None 363 | } 364 | 365 | fn eapi_header_cookies(&self) -> Hm { 366 | let mut hm = Hm::new(); 367 | let mut rng = rand::thread_rng(); 368 | 369 | hm.insert( 370 | "osver".to_owned(), 371 | self.cookie_netease_eapi("osver") 372 | .unwrap_or_else(|| "undefined".to_owned()), 373 | ); 374 | hm.insert( 375 | "deviceId".to_owned(), 376 | self.cookie_netease_eapi("deviceId") 377 | .unwrap_or_else(|| "undefined".to_owned()), 378 | ); 379 | hm.insert( 380 | "appver".to_owned(), 381 | self.cookie_netease_eapi("appver") 382 | .unwrap_or_else(|| "8.0.0".to_owned()), 383 | ); 384 | hm.insert( 385 | "versioncode".to_owned(), 386 | self.cookie_netease_eapi("versioncode") 387 | .unwrap_or_else(|| "140".to_owned()), 388 | ); 389 | hm.insert( 390 | "mobilename".to_owned(), 391 | self.cookie_netease_eapi("mobilename") 392 | .unwrap_or_else(|| "undefined".to_owned()), 393 | ); 394 | hm.insert( 395 | "buildver".to_owned(), 396 | self.cookie_netease_eapi("buildver").unwrap_or_else(|| { 397 | SystemTime::now() 398 | .duration_since(UNIX_EPOCH) 399 | .unwrap() 400 | .as_secs() 401 | .to_string() 402 | }), 403 | ); 404 | hm.insert( 405 | "resolution".to_owned(), 406 | self.cookie_netease_eapi("resolution") 407 | .unwrap_or_else(|| "1920x1080".to_owned()), 408 | ); 409 | hm.insert( 410 | "__csrf".to_owned(), 411 | self.cookie_netease_eapi("__csrf").unwrap_or_default(), 412 | ); 413 | hm.insert( 414 | "os".to_owned(), 415 | self.cookie_netease_eapi("os") 416 | .unwrap_or_else(|| "android".to_owned()), 417 | ); 418 | hm.insert( 419 | "channel".to_owned(), 420 | self.cookie_netease_eapi("channel") 421 | .unwrap_or_else(|| "undefined".to_owned()), 422 | ); 423 | hm.insert( 424 | "requestId".to_owned(), 425 | format!( 426 | "{}_{:04}", 427 | SystemTime::now() 428 | .duration_since(UNIX_EPOCH) 429 | .unwrap() 430 | .as_millis(), 431 | rng.gen_range(0..1000) 432 | ), 433 | ); 434 | 435 | if let Some(val) = self.cookie_netease_eapi("MUSIC_U") { 436 | hm.insert("MUSIC_U".to_owned(), val); 437 | } 438 | if let Some(val) = self.cookie_netease_eapi("MUSIC_A") { 439 | hm.insert("MUSIC_A".to_owned(), val); 440 | } 441 | 442 | hm 443 | } 444 | } 445 | 446 | #[derive(Debug)] 447 | pub(crate) struct Config { 448 | cache: bool, 449 | cache_exp: Duration, 450 | cache_clean_interval: Duration, 451 | 452 | preserve_cookies: bool, 453 | cookie_path: String, 454 | base_url: Url, 455 | 456 | log_request: bool, 457 | log_response: bool, 458 | } 459 | 460 | #[derive(Serialize, Debug, Clone, Copy)] 461 | #[allow(unused)] 462 | pub enum UA { 463 | Chrome, 464 | Edge, 465 | Firefox, 466 | Safari, 467 | Android, 468 | IPhone, 469 | Linux, 470 | } 471 | 472 | fn write_cookies(path: &str, cs: &str) -> TResult<()> { 473 | if !Path::new(path).exists() { 474 | File::create(path).map_err(|_| ApiErr::WriteCookieErr)?; 475 | } 476 | let mut file = OpenOptions::new() 477 | .write(true) 478 | .open(path) 479 | .map_err(|_| ApiErr::WriteCookieErr)?; 480 | 481 | file.write_all(cs.as_bytes()) 482 | .map_err(|_| ApiErr::WriteCookieErr)?; 483 | Ok(()) 484 | } 485 | 486 | fn read_cookies(path: &str) -> TResult { 487 | let mut file = File::open(path).map_err(|_| ApiErr::WriteCookieErr)?; 488 | let mut cs = String::new(); 489 | file.read_to_string(&mut cs) 490 | .map_err(|_| ApiErr::WriteCookieErr)?; 491 | 492 | Ok(cs) 493 | } 494 | 495 | #[allow(unused)] 496 | fn serialize_cookies(cookies: &[Cookie]) -> String { 497 | let s = cookies 498 | .iter() 499 | .map(|c| c.to_string()) 500 | .collect::>() 501 | .join("; "); 502 | s 503 | } 504 | 505 | fn fake_ua(ua: UA) -> &'static str { 506 | match ua { 507 | UA::Chrome => UA_CHROME, 508 | UA::Firefox => UA_FIREFOX, 509 | UA::Safari => UA_SAFARI, 510 | UA::Android => UA_ANDROID, 511 | UA::IPhone => UA_IPHONE, 512 | UA::Edge => UA_CHROME, 513 | UA::Linux => UA_LINUX, 514 | } 515 | } 516 | 517 | fn adapt_url(url: &str, crypto: Crypto) -> String { 518 | let re = Regex::new(r"\w*api").unwrap(); 519 | let u = match crypto { 520 | Crypto::Weapi => re.replace_all(url, "weapi"), 521 | Crypto::Eapi => re.replace_all(url, "eapi"), 522 | Crypto::Linuxapi => Cow::from("https://music.163.com/api/linux/forward"), 523 | }; 524 | u.to_string() 525 | } 526 | 527 | // The reason why directly use Method in reqwest is that i can't find a simple way to 528 | // get a unique id for a api_request, and serialize to json is a compromize way and Method in reqwest 529 | // is not serializable. 530 | fn map_method(method: api_request::Method) -> reqwest::Method { 531 | match method { 532 | api_request::Method::Get => reqwest::Method::GET, 533 | api_request::Method::Head => reqwest::Method::HEAD, 534 | api_request::Method::Post => reqwest::Method::POST, 535 | api_request::Method::Options => reqwest::Method::OPTIONS, 536 | api_request::Method::Connect => reqwest::Method::CONNECT, 537 | api_request::Method::Trace => reqwest::Method::TRACE, 538 | api_request::Method::Delete => reqwest::Method::DELETE, 539 | api_request::Method::Put => reqwest::Method::PUT, 540 | api_request::Method::Patch => reqwest::Method::PATCH, 541 | } 542 | } 543 | 544 | const BASE_URL: &str = "https://music.163.com"; 545 | 546 | const UA_CHROME: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/13.10586"; 547 | const UA_FIREFOX: &str = 548 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:46.0) Gecko/20100101 Firefox/46.0"; 549 | const UA_SAFARI: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"; 550 | const UA_ANDROID: &str = "Mozilla/5.0 (Linux; Android 9; PCT-AL10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.64 HuaweiBrowser/10.0.3.311 Mobile Safari/537.36"; 551 | const UA_IPHONE: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1"; 552 | const UA_LINUX: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"; 553 | 554 | #[cfg(test)] 555 | mod tests { 556 | use crate::client::route::API_ROUTE; 557 | use crate::client::ApiClientBuilder; 558 | 559 | use super::*; 560 | use serde_json::json; 561 | 562 | const COOKIE_PATH: &str = "/var/tmp/ncmapi_client_cookies"; 563 | 564 | type Rb = api_request::ApiRequestBuilder; 565 | 566 | fn create_search_req() -> ApiRequest { 567 | Rb::post(API_ROUTE["cloudsearch"]) 568 | .set_data(json!({ 569 | "s": "mota", 570 | "type": 1, 571 | })) 572 | .insert("offset", json!(0)) 573 | .merge(json!({"limit": 1})) 574 | .build() 575 | } 576 | 577 | #[test] 578 | fn test_client() { 579 | let cb = ApiClientBuilder::new(COOKIE_PATH) 580 | .cache(true) 581 | .preserve_cookies(true) 582 | .log_request(true); 583 | 584 | let res = cb.build(); 585 | 586 | assert!(res.is_ok()); 587 | } 588 | 589 | #[test] 590 | fn test_to_http_request() { 591 | let r = Rb::post(API_ROUTE["cloudsearch"]) 592 | .set_data(json!({ 593 | "s": "mota", 594 | "type": 1, 595 | })) 596 | .insert("offset", json!(0)) 597 | .merge(json!({"limit": 3})) 598 | .set_api_url("/api/url") 599 | .set_real_ip("real_ip") 600 | .set_ua(UA::IPhone) 601 | .set_cookies(Hm::new()) 602 | .add_cookie("sid", "f1h82fg191fh9") 603 | .build(); 604 | 605 | let c = ApiClientBuilder::new(COOKIE_PATH).build().unwrap(); 606 | let http_req = c.to_http_request(r); 607 | 608 | assert!(http_req.is_ok()); 609 | } 610 | 611 | #[tokio::test(flavor = "multi_thread")] 612 | async fn test_request() { 613 | let c = ApiClientBuilder::new(COOKIE_PATH) 614 | .log_request(true) 615 | .build() 616 | .unwrap(); 617 | let r = create_search_req(); 618 | 619 | let resp = c.request(r).await; 620 | assert!(resp.is_ok()); 621 | let res = resp.unwrap().deserialize_to_implict(); 622 | assert_eq!(res.code, 200); 623 | } 624 | 625 | #[tokio::test(flavor = "multi_thread")] 626 | async fn test_cache() { 627 | let c = ApiClientBuilder::new(COOKIE_PATH).build().unwrap(); 628 | 629 | let r = create_search_req(); 630 | let resp = c.request(r).await; 631 | assert!(resp.is_ok()); 632 | let res = resp.unwrap().deserialize_to_implict(); 633 | assert_eq!(res.code, 200); 634 | std::thread::sleep(std::time::Duration::from_secs(10)); 635 | 636 | let r = create_search_req(); 637 | let resp = c.request(r).await; 638 | assert!(resp.is_ok()); 639 | let res = resp.unwrap().deserialize_to_implict(); 640 | assert_eq!(res.code, 200); 641 | std::thread::sleep(std::time::Duration::from_secs(10)); 642 | 643 | let r = create_search_req(); 644 | let resp = c.request(r).await; 645 | assert!(resp.is_ok()); 646 | let res = resp.unwrap().deserialize_to_implict(); 647 | assert_eq!(res.code, 200); 648 | std::thread::sleep(std::time::Duration::from_secs(10)); 649 | } 650 | 651 | #[test] 652 | fn test_read_cookies() { 653 | let res = read_cookies(COOKIE_PATH); 654 | assert!(res.is_ok()); 655 | } 656 | 657 | #[test] 658 | fn test_write_cookies() { 659 | let res = write_cookies(COOKIE_PATH, "name=alex; age=19"); 660 | assert!(res.is_ok()) 661 | } 662 | 663 | #[test] 664 | fn test_eapi_headers() { 665 | let c = ApiClientBuilder::new(COOKIE_PATH).build().unwrap(); 666 | 667 | let c = c.eapi_header_cookies(); 668 | println!("{}", c.get("requestId").unwrap()); 669 | } 670 | } 671 | -------------------------------------------------------------------------------- /src/client/route.rs: -------------------------------------------------------------------------------- 1 | use phf::{phf_map, Map}; 2 | 3 | pub(crate) static API_ROUTE: Map<&'static str, &'static str> = phf_map! { 4 | "activate_init_profile"=> "https://music.163.com/eapi/activate/initProfile", 5 | "album_detail_dynamic"=> "https://music.163.com/api/album/detail/dynamic", 6 | "album_detail"=> "https://music.163.com/weapi/vipmall/albumproduct/detail", 7 | "album"=> "https://music.163.com/weapi/v1/album/${query.id}", 8 | "album_list"=> "https://music.163.com/weapi/vipmall/albumproduct/list", 9 | "album_list_style"=> "https://music.163.com/weapi/vipmall/appalbum/album/style", 10 | "album_newest"=> "https://music.163.com/api/discovery/newAlbum", 11 | "album_new"=> "https://music.163.com/weapi/album/new", 12 | "album_songsaleboard"=> "https://music.163.com/api/feealbum/songsaleboard/${type}/type", 13 | "album_sub"=> "https://music.163.com/api/album/${query.t}", 14 | "album_sublist"=> "https://music.163.com/weapi/album/sublist", 15 | "artist_album"=> "https://music.163.com/weapi/artist/albums/${query.id}", 16 | "artist_desc"=> "https://music.163.com/weapi/artist/introduction", 17 | "artist_detail"=> "https://music.163.com/api/artist/head/info/get", 18 | "artist_fans"=> "https://music.163.com/weapi/artist/fans/get", 19 | "artist_list"=> "https://music.163.com/api/v1/artist/list", 20 | "artist_mv"=> "https://music.163.com/weapi/artist/mvs", 21 | "artist_new_mv"=> "https://music.163.com/api/sub/artist/new/works/mv/list", 22 | "artist_new_song"=> "https://music.163.com/api/sub/artist/new/works/song/list", 23 | "artists"=> "https://music.163.com/weapi/v1/artist/${query.id}", 24 | "artist_songs"=> "https://music.163.com/api/v1/artist/songs", 25 | "artist_sub"=> "https://music.163.com/weapi/artist/${query.t}", 26 | "artist_sublist"=> "https://music.163.com/weapi/artist/sublist", 27 | "artist_top_song"=> "https://music.163.com/api/artist/top/song", 28 | "audio_match"=> "https://music.163.com/api/music/audio/match", 29 | "avatar_upload"=> "https://music.163.com/weapi/user/avatar/upload/v1", 30 | "banner"=> "https://music.163.com/api/v2/banner/get", 31 | "batch"=> "https://music.163.com/eapi/batch", 32 | "calendar"=> "https://music.163.com/api/mcalendar/detail", 33 | "captcha_sent"=> "https://music.163.com/api/sms/captcha/sent", 34 | "captcha_verify"=> "https://music.163.com/weapi/sms/captcha/verify", 35 | "cellphone_existence_check"=> "https://music.163.com/eapi/cellphone/existence/check", 36 | "check_music"=> "https://music.163.com/weapi/song/enhance/player/url", 37 | "cloud"=> "https://interface.music.163.com/api/cloud/upload/check", 38 | // ""https"=>//music.163.com/weapi/nos/token/alloc", 39 | // ""https"=>//music.163.com/api/upload/cloud/info/v2", 40 | // ""https"=>//interface.music.163.com/api/cloud/pub/v2", 41 | "cloud_match"=> "https://music.163.com/api/cloud/user/song/match", 42 | "cloudsearch"=> "https://music.163.com/api/cloudsearch/pc", 43 | "comment_album"=> "https://music.163.com/weapi/v1/resource/comments/R_AL_3_${query.id}", 44 | "comment_dj"=> "https://music.163.com/weapi/v1/resource/comments/A_DJ_1_${query.id}", 45 | "comment_event"=> "https://music.163.com/weapi/v1/resource/comments/${query.threadId}", 46 | "comment_floor"=> "https://music.163.com/api/resource/comment/floor/get", 47 | "comment_hot"=> "https://music.163.com/weapi/v1/resource/hotcomments/${query.type}${query.id}", 48 | "comment_hug_list"=> "https://music.163.com/api/v2/resource/comments/hug/list", 49 | "comment"=> "https://music.163.com/weapi/resource/comments/${query.t}", 50 | "comment_like"=> "https://music.163.com/weapi/v1/comment/${query.t}", 51 | "comment_music"=> "https://music.163.com/api/v1/resource/comments/R_SO_4_${query.id}", 52 | "comment_mv"=> "https://music.163.com/weapi/v1/resource/comments/R_MV_5_${query.id}", 53 | "comment_new"=> "https://music.163.com/api/v2/resource/comments", 54 | "comment_playlist"=> "https://music.163.com/weapi/v1/resource/comments/A_PL_0_${query.id}", 55 | "comment_video"=> "https://music.163.com/weapi/v1/resource/comments/R_VI_62_${query.id}", 56 | "countries_code_list"=> "https://interface3.music.163.com/eapi/lbs/countries/v1", 57 | "daily_signin"=> "https://music.163.com/weapi/point/dailyTask", 58 | "digitalAlbum_detail"=> "https://music.163.com/weapi/vipmall/albumproduct/detail", 59 | "digitalAlbum_ordering"=> "https://music.163.com/api/ordering/web/digital", 60 | "digitalAlbum_purchased"=> "https://music.163.com/api/digitalAlbum/purchased", 61 | "digitalAlbum_sales"=> "https://music.163.com/weapi/vipmall/albumproduct/album/query/sales", 62 | "dj_banner"=> "https://music.163.com/weapi/djradio/banner/get", 63 | "dj_category_excludehot"=> "https://music.163.com/weapi/djradio/category/excludehot", 64 | "dj_category_recommend"=> "https://music.163.com/weapi/djradio/home/category/recommend", 65 | "dj_catelist"=> "https://music.163.com/weapi/djradio/category/get", 66 | "dj_detail"=> "https://music.163.com/api/djradio/v2/get", 67 | "dj_hot"=> "https://music.163.com/weapi/djradio/hot/v1", 68 | "dj_paygift"=> "https://music.163.com/weapi/djradio/home/paygift/list?_nmclfl=1", 69 | "dj_personalize_recommend"=> "https://music.163.com/api/djradio/personalize/rcmd", 70 | "dj_program_detail"=> "https://music.163.com/api/dj/program/detail", 71 | "dj_program"=> "https://music.163.com/weapi/dj/program/byradio", 72 | "dj_program_toplist_hours"=> "https://music.163.com/api/djprogram/toplist/hours", 73 | "dj_program_toplist"=> "https://music.163.com/api/program/toplist/v1", 74 | "dj_radio_hot"=> "https://music.163.com/api/djradio/hot", 75 | "dj_recommend"=> "https://music.163.com/weapi/djradio/recommend/v1", 76 | "dj_recommend_type"=> "https://music.163.com/weapi/djradio/recommend", 77 | "dj_sub"=> "https://music.163.com/weapi/djradio/${query.t}", 78 | "dj_sublist"=> "https://music.163.com/weapi/djradio/get/subed", 79 | "dj_subscriber"=> "https://music.163.com/api/djradio/subscriber", 80 | "dj_today_perfered"=> "https://music.163.com/weapi/djradio/home/today/perfered", 81 | "dj_toplist_hours"=> "https://music.163.com/api/dj/toplist/hours", 82 | "dj_toplist"=> "https://music.163.com/api/djradio/toplist", 83 | "dj_toplist_newcomer"=> "https://music.163.com/api/dj/toplist/newcomer", 84 | "dj_toplist_pay"=> "https://music.163.com/api/djradio/toplist/pay", 85 | "dj_toplist_popular"=> "https://music.163.com/api/dj/toplist/popular", 86 | "event_del"=> "https://music.163.com/eapi/event/delete", 87 | "event_forward"=> "https://music.163.com/weapi/event/forward", 88 | "event"=> "https://music.163.com/weapi/v1/event/get", 89 | "fm_trash"=> "https://music.163.com/weapi/radio/trash/add?alg=RT&songId=%v&time=%v", 90 | "follow"=> "https://music.163.com/weapi/user/${query.t}/${query.id}", 91 | "history_recommend_songs_detail"=> "https://music.163.com/api/discovery/recommend/songs/history/detail", 92 | "history_recommend_songs"=> "https://music.163.com/api/discovery/recommend/songs/history/recent", 93 | "hot_topic"=> "https://music.163.com/api/act/hot", 94 | "hug_comment"=> "https://music.163.com/api/v2/resource/comments/hug/listener", 95 | "like"=> "https://music.163.com/api/radio/like", 96 | "likelist"=> "https://music.163.com/weapi/song/like/get", 97 | "listen_together_status"=> "https://music.163.com/api/listen/together/status/get", 98 | "login_cellphone"=> "https://music.163.com/api/login/cellphone", 99 | "login"=> "https://music.163.com/weapi/login", 100 | "login_qr_check"=> "https://music.163.com/weapi/login/qrcode/client/login", 101 | "login_qr_create"=> "https://music.163.com/login?codekey=${query.key}", 102 | "login_qr_key"=> "https://music.163.com/weapi/login/qrcode/unikey", 103 | "login_refresh"=> "https://music.163.com/weapi/login/token/refresh", 104 | "login_status"=> "https://music.163.com/weapi/w/nuser/account/get", 105 | "logout"=> "https://music.163.com/weapi/logout", 106 | "lyric"=> "https://music.163.com/api/song/lyric", 107 | "mlog_to_video"=> "https://music.163.com/weapi/mlog/video/convert/id", 108 | "mlog_url"=> "https://music.163.com/weapi/mlog/detail/v1", 109 | "msg_comments"=> "https://music.163.com/api/v1/user/comments/${query.uid}", 110 | "msg_forwards"=> "https://music.163.com/api/forwards/get", 111 | "msg_notices"=> "https://music.163.com/api/msg/notices", 112 | "msg_private_history"=> "https://music.163.com/api/msg/private/history", 113 | "msg_private"=> "https://music.163.com/api/msg/private/users", 114 | "msg_recentcontact"=> "https://music.163.com/api/msg/recentcontact/get", 115 | "musician_cloudbean"=> "https://music.163.com/weapi/cloudbean/get", 116 | "musician_cloudbean_obtain"=> "https://music.163.com/weapi/nmusician/workbench/mission/reward/obtain/new", 117 | "musician_data_overview"=> "https://music.163.com/weapi/creator/musician/statistic/data/overview/get", 118 | "musician_play_trend"=> "https://music.163.com/weapi/creator/musician/play/count/statistic/data/trend/get", 119 | "musician_tasks"=> "https://music.163.com/weapi/nmusician/workbench/mission/cycle/list", 120 | "mv_all"=> "https://interface.music.163.com/api/mv/all", 121 | "mv_detail_info"=> "https://music.163.com/api/comment/commentthread/info", 122 | "mv_detail"=> "https://music.163.com/api/v1/mv/detail", 123 | "mv_exclusive_rcmd"=> "https://interface.music.163.com/api/mv/exclusive/rcmd", 124 | "mv_first"=> "https://interface.music.163.com/weapi/mv/first", 125 | "mv_sub"=> "https://music.163.com/weapi/mv/${query.t}", 126 | "mv_sublist"=> "https://music.163.com/weapi/cloudvideo/allvideo/sublist", 127 | "mv_url"=> "https://music.163.com/weapi/song/enhance/play/mv/url", 128 | "personal_fm"=> "https://music.163.com/weapi/v1/radio/get", 129 | "personalized_djprogram"=> "https://music.163.com/weapi/personalized/djprogram", 130 | "personalized"=> "https://music.163.com/weapi/personalized/playlist", 131 | "personalized_mv"=> "https://music.163.com/weapi/personalized/mv", 132 | "personalized_newsong"=> "https://music.163.com/api/personalized/newsong", 133 | "personalized_privatecontent"=> "https://music.163.com/weapi/personalized/privatecontent", 134 | "personalized_privatecontent_list"=> "https://music.163.com/api/v2/privatecontent/list", 135 | "playlist_catlist"=> "https://music.163.com/weapi/playlist/catalogue", 136 | "playlist_cover_update"=> "https://music.163.com/weapi/playlist/cover/update", 137 | "playlist_create"=> "https://music.163.com/api/playlist/create", 138 | "playlist_delete"=> "https://music.163.com/weapi/playlist/remove", 139 | "playlist_desc_update"=> "https://interface3.music.163.com/eapi/playlist/desc/update", 140 | "playlist_detail_dynamic"=> "https://music.163.com/api/playlist/detail/dynamic", 141 | "playlist_detail"=> "https://music.163.com/api/v6/playlist/detail", 142 | "playlist_highquality_tags"=> "https://music.163.com/api/playlist/highquality/tags", 143 | "playlist_hot"=> "https://music.163.com/weapi/playlist/hottags", 144 | "playlist_mylike"=> "https://music.163.com/api/mlog/playlist/mylike/bytime/get", 145 | "playlist_name_update"=> "https://interface3.music.163.com/eapi/playlist/update/name", 146 | "playlist_order_update"=> "https://music.163.com/api/playlist/order/update", 147 | "playlist_subscribe"=> "https://music.163.com/weapi/playlist/${query.t}", 148 | "playlist_subscribers"=> "https://music.163.com/weapi/playlist/subscribers", 149 | "playlist_tags_update"=> "https://interface3.music.163.com/eapi/playlist/tags/update", 150 | "playlist_track_add"=> "https://music.163.com/api/playlist/track/add", 151 | "playlist_track_delete"=> "https://music.163.com/api/playlist/track/delete", 152 | "playlist_tracks"=> "https://music.163.com/api/playlist/manipulate/tracks", 153 | // ""https"=>//music.163.com/api/playlist/manipulate/tracks", 154 | "playlist_update"=> "https://music.163.com/weapi/batch", 155 | "playlist_video_recent"=> "https://music.163.com/api/playlist/video/recent", 156 | "playmode_intelligence_list"=> "https://music.163.com/weapi/playmode/intelligence/list", 157 | "program_recommend"=> "https://music.163.com/weapi/program/recommend/v1", 158 | "rebind"=> "https://music.163.com/api/user/replaceCellphone", 159 | "recommend_resource"=> "https://music.163.com/weapi/v1/discovery/recommend/resource", 160 | "recommend_songs"=> "https://music.163.com/api/v3/discovery/recommend/songs", 161 | "register_cellphone"=> "https://music.163.com/api/register/cellphone", 162 | "related_allvideo"=> "https://music.163.com/weapi/cloudvideo/v1/allvideo/rcmd", 163 | "related_playlist"=> "https://music.163.com/playlist?id=${query.id}", 164 | "resource_like"=> "https://music.163.com/weapi/resource/${query.t}", 165 | "scrobble"=> "https://music.163.com/weapi/feedback/weblog", 166 | "search_default"=> "https://interface3.music.163.com/eapi/search/defaultkeyword/get", 167 | "search_hot_detail"=> "https://music.163.com/weapi/hotsearchlist/get", 168 | "search_hot"=> "https://music.163.com/weapi/search/hot", 169 | "search"=> "https://music.163.com/weapi/search/get", 170 | "search_multimatch"=> "https://music.163.com/weapi/search/suggest/multimatch", 171 | "search_suggest"=> "https://music.163.com/weapi/search/suggest/", 172 | "send_album"=> "https://music.163.com/api/msg/private/send", 173 | "send_playlist"=> "https://music.163.com/weapi/msg/private/send", 174 | "send_song"=> "https://music.163.com/api/msg/private/send", 175 | "send_text"=> "https://music.163.com/weapi/msg/private/send", 176 | "setting"=> "https://music.163.com/api/user/setting", 177 | "share_resource"=> "https://music.163.com/weapi/share/friends/resource", 178 | "simi_artist"=> "https://music.163.com/weapi/discovery/simiArtist", 179 | "simi_mv"=> "https://music.163.com/weapi/discovery/simiMV", 180 | "simi_playlist"=> "https://music.163.com/weapi/discovery/simiPlaylist", 181 | "simi_song"=> "https://music.163.com/weapi/v1/discovery/simiSong", 182 | "simi_user"=> "https://music.163.com/weapi/discovery/simiUser", 183 | "song_detail"=> "https://music.163.com/api/v3/song/detail", 184 | // "song_order_update"=> ", 185 | "song_purchased"=> "https://music.163.com/weapi/single/mybought/song/list", 186 | "song_url"=> "https://interface3.music.163.com/eapi/song/enhance/player/url", 187 | "top_album"=> "https://music.163.com/api/discovery/new/albums/area", 188 | "top_artists"=> "https://music.163.com/weapi/artist/top", 189 | "topic_detail_event_hot"=> "https://music.163.com/api/act/event/hot", 190 | "topic_detail"=> "https://music.163.com/api/act/detail", 191 | "topic_sublist"=> "https://music.163.com/api/topic/sublist", 192 | "toplist_artist"=> "https://music.163.com/weapi/toplist/artist", 193 | "toplist_detail"=> "https://music.163.com/weapi/toplist/detail", 194 | "top_list"=> "https://interface3.music.163.com/api/playlist/v4/detail", 195 | "toplist"=> "https://music.163.com/api/toplist", 196 | "top_mv"=> "https://music.163.com/weapi/mv/toplist", 197 | "top_playlist_highquality"=> "https://music.163.com/api/playlist/highquality/list", 198 | "top_playlist"=> "https://music.163.com/weapi/playlist/list", 199 | "top_song"=> "https://music.163.com/weapi/v1/discovery/new/songs", 200 | "user_account"=> "https://music.163.com/api/nuser/account/get", 201 | "user_audio"=> "https://music.163.com/weapi/djradio/get/byuser", 202 | "user_bindingcellphone"=> "https://music.163.com/api/user/bindingCellphone", 203 | "user_binding"=> "https://music.163.com/api/v1/user/bindings/${query.uid}", 204 | "user_cloud_del"=> "https://music.163.com/weapi/cloud/del", 205 | "user_cloud_detail"=> "https://music.163.com/weapi/v1/cloud/get/byids", 206 | "user_cloud"=> "https://music.163.com/api/v1/cloud/get", 207 | "user_comment_history"=> "https://music.163.com/api/comment/user/comment/history", 208 | "user_detail"=> "https://music.163.com/weapi/v1/user/detail/${query.uid}", 209 | "user_dj"=> "https://music.163.com/weapi/dj/program/${query.uid}", 210 | "user_event"=> "https://music.163.com/api/event/get/${query.uid}", 211 | "user_followeds"=> "https://music.163.com/eapi/user/getfolloweds/${query.uid}", 212 | "user_follows"=> "https://music.163.com/weapi/user/getfollows/${query.uid}", 213 | "user_level"=> "https://music.163.com/weapi/user/level", 214 | "user_playlist"=> "https://music.163.com/api/user/playlist", 215 | "user_record"=> "https://music.163.com/weapi/v1/play/record", 216 | "user_replacephone"=> "https://music.163.com/api/user/replaceCellphone", 217 | "user_subcount"=> "https://music.163.com/weapi/subcount", 218 | "user_update"=> "https://music.163.com/weapi/user/profile/update", 219 | "video_category_list"=> "https://music.163.com/api/cloudvideo/category/list", 220 | "video_detail_info"=> "https://music.163.com/api/comment/commentthread/info", 221 | "video_detail"=> "https://music.163.com/weapi/cloudvideo/v1/video/detail", 222 | "video_group"=> "https://music.163.com/api/videotimeline/videogroup/otherclient/get", 223 | "video_group_list"=> "https://music.163.com/api/cloudvideo/group/list", 224 | "video_sub"=> "https://music.163.com/weapi/cloudvideo/video/${query.t}", 225 | "video_timeline_all"=> "https://music.163.com/api/videotimeline/otherclient/get", 226 | "video_timeline_recommend"=> "https://music.163.com/api/videotimeline/get", 227 | "video_url"=> "https://music.163.com/weapi/cloudvideo/playurl", 228 | "vip_growthpoint_details"=> "https://music.163.com/weapi/vipnewcenter/app/level/growth/details", 229 | "vip_growthpoint_get"=> "https://music.163.com/weapi/vipnewcenter/app/level/task/reward/get", 230 | "vip_growthpoint"=> "https://music.163.com/weapi/vipnewcenter/app/level/growhpoint/basic", 231 | "vip_tasks"=> "https://music.163.com/weapi/vipnewcenter/app/level/task/list", 232 | "weblog"=> "https://music.163.com/weapi/feedback/weblog", 233 | "yunbei_expense"=> "https://music.163.com/store/api/point/expense", 234 | "yunbei_info"=> "https://music.163.com/api/v1/user/info", 235 | "yunbei"=> "https://music.163.com/api/point/signed/get", 236 | "yunbei_rcmd_song_history"=> "https://music.163.com/weapi/yunbei/rcmd/song/history/list", 237 | "yunbei_rcmd_song"=> "https://music.163.com/weapi/yunbei/rcmd/song/submit", 238 | "yunbei_receipt"=> "https://music.163.com/store/api/point/receipt", 239 | "yunbei_sign"=> "https://music.163.com/api/point/dailyTask", 240 | "yunbei_task_finish"=> "https://music.163.com/api/usertool/task/point/receive", 241 | "yunbei_tasks"=> "https://music.163.com/api/usertool/task/list/all", 242 | "yunbei_tasks_todo"=> "https://music.163.com/api/usertool/task/todo/query", 243 | "yunbei_today"=> "https://music.163.com/api/point/today/get", 244 | }; 245 | -------------------------------------------------------------------------------- /src/client/store.rs: -------------------------------------------------------------------------------- 1 | use memory_cache::MemoryCache; 2 | 3 | use super::ApiResponse; 4 | use std::{sync::RwLock, time}; 5 | 6 | pub(crate) trait InMemStore { 7 | #[allow(clippy::ptr_arg)] 8 | fn get(&self, id: &String) -> Option; 9 | 10 | #[allow(clippy::ptr_arg)] 11 | fn contains_key(&self, id: &String) -> bool; 12 | 13 | fn insert( 14 | &self, 15 | id: String, 16 | val: ApiResponse, 17 | lifetime: Option, 18 | ) -> Option; 19 | } 20 | 21 | pub(crate) struct Store(RwLock>); 22 | 23 | impl Store { 24 | pub fn new(scan_interval: time::Duration) -> Self { 25 | Self(RwLock::new(MemoryCache::with_full_scan(scan_interval))) 26 | } 27 | } 28 | 29 | impl InMemStore for Store { 30 | fn get(&self, id: &String) -> Option { 31 | if let Some(res) = self.0.read().unwrap().get(id) { 32 | return Some(ApiResponse::new(res.data().to_owned())); 33 | } 34 | None 35 | } 36 | 37 | fn contains_key(&self, id: &String) -> bool { 38 | self.0.read().unwrap().contains_key(id) 39 | } 40 | 41 | fn insert( 42 | &self, 43 | id: String, 44 | val: ApiResponse, 45 | lifetime: Option, 46 | ) -> Option { 47 | self.0.write().unwrap().insert(id, val, lifetime) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/crypto/key.rs: -------------------------------------------------------------------------------- 1 | pub(super) const IV: &str = "0102030405060708"; 2 | pub(super) const PRESET_KEY: &str = "0CoJUm6Qyw8W8jud"; 3 | pub(super) const LINUX_API_KEY: &str = "rFgB&h#%2?^eDg:Q"; 4 | pub(super) const BASE62: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 5 | pub(super) const PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----"; 6 | pub(super) const EAPI_KEY: &str = "e82ckenh8dichen8"; 7 | -------------------------------------------------------------------------------- /src/crypto/mod.rs: -------------------------------------------------------------------------------- 1 | mod key; 2 | 3 | use openssl::{ 4 | error::ErrorStack, 5 | hash::{hash, MessageDigest}, 6 | rsa::{Padding, Rsa}, 7 | symm::{decrypt, encrypt, Cipher}, 8 | }; 9 | use rand::RngCore; 10 | use serde::Serialize; 11 | 12 | use key::{BASE62, EAPI_KEY, IV, LINUX_API_KEY, PRESET_KEY, PUBLIC_KEY}; 13 | 14 | #[derive(Serialize, Debug, PartialEq, Eq, Clone, Copy)] 15 | pub enum Crypto { 16 | Weapi, 17 | Eapi, 18 | #[allow(unused)] 19 | Linuxapi, 20 | } 21 | 22 | pub struct WeapiForm { 23 | params: String, 24 | enc_sec_key: String, 25 | } 26 | 27 | pub struct EapiForm { 28 | params: String, 29 | } 30 | pub struct LinuxapiForm { 31 | eparams: String, 32 | } 33 | 34 | impl WeapiForm { 35 | pub fn into_vec(self) -> Vec<(String, String)> { 36 | vec![ 37 | ("params".to_owned(), self.params), 38 | ("encSecKey".to_owned(), self.enc_sec_key), 39 | ] 40 | } 41 | } 42 | 43 | impl EapiForm { 44 | pub fn into_vec(self) -> Vec<(String, String)> { 45 | vec![("params".to_owned(), self.params)] 46 | } 47 | } 48 | 49 | impl LinuxapiForm { 50 | pub fn into_vec(self) -> Vec<(String, String)> { 51 | vec![("eparams".to_owned(), self.eparams)] 52 | } 53 | } 54 | 55 | pub fn weapi(text: &[u8]) -> WeapiForm { 56 | let mut rng = rand::thread_rng(); 57 | let mut rand_buf = [0u8; 16]; 58 | rng.fill_bytes(&mut rand_buf); 59 | 60 | let sk = rand_buf 61 | .iter() 62 | .map(|i| BASE62.as_bytes()[(i % 62) as usize]) 63 | .collect::>(); 64 | 65 | let params = { 66 | let p = base64::encode(aes_128_cbc( 67 | text, 68 | PRESET_KEY.as_bytes(), 69 | Some(IV.as_bytes()), 70 | )); 71 | base64::encode(aes_128_cbc(p.as_bytes(), &sk, Some(IV.as_bytes()))) 72 | }; 73 | 74 | let enc_sec_key = { 75 | let reversed_sk = sk.iter().rev().copied().collect::>(); 76 | hex::encode(rsa(&reversed_sk, PUBLIC_KEY.as_bytes())) 77 | }; 78 | 79 | WeapiForm { 80 | params, 81 | enc_sec_key, 82 | } 83 | } 84 | 85 | pub fn eapi(url: &[u8], data: &[u8]) -> EapiForm { 86 | let msg = format!( 87 | "nobody{}use{}md5forencrypt", 88 | String::from_utf8_lossy(url), 89 | String::from_utf8_lossy(data) 90 | ); 91 | let digest = hex::encode(hash(MessageDigest::md5(), msg.as_bytes()).unwrap()); 92 | 93 | let text = { 94 | let d = "-36cd479b6b5-"; 95 | [url, d.as_bytes(), data, d.as_bytes(), digest.as_bytes()].concat() 96 | }; 97 | 98 | let params = { 99 | let p = aes_128_ecb(&text, EAPI_KEY.as_bytes(), None); 100 | hex::encode_upper(p) 101 | }; 102 | 103 | EapiForm { params } 104 | } 105 | 106 | #[allow(unused)] 107 | pub fn eapi_decrypt(ct: &[u8]) -> Result, ErrorStack> { 108 | aes_128_ecb_decrypt(ct, EAPI_KEY.as_bytes(), None) 109 | } 110 | 111 | pub fn linuxapi(text: &[u8]) -> LinuxapiForm { 112 | let ct = aes_128_ecb(text, LINUX_API_KEY.as_bytes(), None); 113 | let eparams = hex::encode_upper(ct); 114 | 115 | LinuxapiForm { eparams } 116 | } 117 | 118 | fn aes_128_ecb(pt: &[u8], key: &[u8], iv: Option<&[u8]>) -> Vec { 119 | let cipher = Cipher::aes_128_ecb(); 120 | encrypt(cipher, key, iv, pt).unwrap() 121 | } 122 | 123 | fn aes_128_ecb_decrypt(ct: &[u8], key: &[u8], iv: Option<&[u8]>) -> Result, ErrorStack> { 124 | let cipher = Cipher::aes_128_ecb(); 125 | decrypt(cipher, key, iv, ct) 126 | } 127 | 128 | fn aes_128_cbc(pt: &[u8], key: &[u8], iv: Option<&[u8]>) -> Vec { 129 | let cipher = Cipher::aes_128_cbc(); 130 | encrypt(cipher, key, iv, pt).unwrap() 131 | } 132 | 133 | fn rsa(pt: &[u8], key: &[u8]) -> Vec { 134 | let rsa = Rsa::public_key_from_pem(key).unwrap(); 135 | 136 | let prefix = vec![0u8; 128 - pt.len()]; 137 | let pt = [&prefix[..], pt].concat(); 138 | 139 | let mut ct = vec![0; rsa.size() as usize]; 140 | rsa.public_encrypt(&pt, &mut ct, Padding::NONE).unwrap(); 141 | ct 142 | } 143 | 144 | #[cfg(test)] 145 | mod tests { 146 | use super::key::{EAPI_KEY, IV, PRESET_KEY, PUBLIC_KEY}; 147 | use super::{aes_128_cbc, aes_128_ecb, aes_128_ecb_decrypt, rsa, weapi}; 148 | use crate::crypto::{eapi, eapi_decrypt, linuxapi}; 149 | 150 | #[test] 151 | fn test_aes_128_ecb() { 152 | let pt = "plain text"; 153 | let ct = aes_128_ecb(pt.as_bytes(), EAPI_KEY.as_bytes(), None); 154 | let _pt = aes_128_ecb_decrypt(&ct, EAPI_KEY.as_bytes(), None); 155 | assert!(_pt.is_ok()); 156 | 157 | if let Ok(decrypted) = _pt { 158 | assert_eq!(&decrypted, pt.as_bytes()); 159 | } 160 | } 161 | 162 | #[test] 163 | fn test_aes_cbc() { 164 | let pt = "plain text"; 165 | let ct = aes_128_cbc(pt.as_bytes(), PRESET_KEY.as_bytes(), Some(IV.as_bytes())); 166 | assert!(hex::encode(ct).ends_with("baf0")) 167 | } 168 | 169 | #[test] 170 | fn test_rsa() { 171 | let ct = rsa(PRESET_KEY.as_bytes(), PUBLIC_KEY.as_bytes()); 172 | assert!(hex::encode(ct).ends_with("4413")); 173 | } 174 | 175 | #[test] 176 | fn test_weapi() { 177 | weapi(r#"{"username": "alex"}"#.as_bytes()); 178 | } 179 | 180 | #[test] 181 | fn test_eapi() { 182 | let ct = eapi("/url".as_bytes(), "plain text".as_bytes()); 183 | assert!(ct.params.ends_with("C3F3")); 184 | } 185 | 186 | #[test] 187 | fn test_eapi_decrypt() { 188 | let pt = "plain text"; 189 | let ct = aes_128_ecb(pt.as_bytes(), EAPI_KEY.as_bytes(), None); 190 | assert_eq!(pt.as_bytes(), &eapi_decrypt(&ct).unwrap()) 191 | } 192 | 193 | #[test] 194 | fn test_linuxapi() { 195 | let ct = linuxapi(r#""plain text""#.as_bytes()); 196 | assert!(ct.eparams.ends_with("2250")); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! NetEase Cloud Music API For Rust. 2 | 3 | mod api; 4 | mod client; 5 | mod crypto; 6 | pub mod types; 7 | 8 | pub use api::{NcmApi, ResourceType, SearchType}; 9 | use thiserror::Error; 10 | 11 | #[derive(Debug, Error)] 12 | pub enum ApiErr { 13 | #[error("reqwest error")] 14 | ReqwestErr, 15 | 16 | #[error("deserialize error")] 17 | DeserializeErr, 18 | 19 | #[error("parse url error")] 20 | ParseUrlErr, 21 | 22 | #[error("read cookie error")] 23 | ReadCookieErr, 24 | 25 | #[error("write cookie error")] 26 | WriteCookieErr, 27 | } 28 | 29 | type TResult = std::result::Result; 30 | type TError = ApiErr; 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::NcmApi; 35 | 36 | #[tokio::test(flavor = "multi_thread")] 37 | async fn test_search() { 38 | let api = NcmApi::default(); 39 | let resp = api.search("mota", None).await; 40 | assert!(resp.is_ok()); 41 | 42 | let res = resp.unwrap(); 43 | let res = res.deserialize_to_implict(); 44 | assert_eq!(res.code, 200); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/types/mod.rs: -------------------------------------------------------------------------------- 1 | // This module was generated at https://transform.tools/json-to-rust-serde 2 | // However, some fields of struct was stripped for concision. 3 | pub type SearchSongResp = ResultResp; 4 | pub type SearchArtistResp = ResultResp; 5 | pub type SearchPodcastResp = ResultResp; 6 | pub type SearchPlaylistResp = ResultResp; 7 | pub type SearchAlbumResp = ResultResp; 8 | 9 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct ResultResp { 12 | pub code: usize, 13 | pub result: Option, 14 | } 15 | 16 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 17 | #[serde(rename_all = "camelCase", default)] 18 | pub struct SearchResultSong { 19 | pub songs: Vec, 20 | pub has_more: bool, 21 | } 22 | 23 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 24 | #[serde(rename_all = "camelCase")] 25 | pub struct Song { 26 | pub id: usize, 27 | pub name: String, 28 | #[serde(alias = "ar")] 29 | pub artists: Vec, 30 | #[serde(alias = "al")] 31 | pub album: Album, 32 | #[serde(alias = "dt")] 33 | pub duration: usize, 34 | pub fee: usize, 35 | #[serde(alias = "popularity")] 36 | pub pop: f32, 37 | // pub resource_state: bool, 38 | // pub publish_time: i64, 39 | } 40 | 41 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 42 | #[serde(rename_all = "camelCase")] 43 | pub struct Artist { 44 | pub id: usize, 45 | pub name: Option, 46 | } 47 | 48 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 49 | #[serde(rename_all = "camelCase")] 50 | pub struct Album { 51 | pub id: usize, 52 | pub name: Option, 53 | #[serde(default)] 54 | pub pic_url: String, 55 | pub pic: usize, 56 | } 57 | 58 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 59 | #[serde(rename_all = "camelCase")] 60 | pub struct PodcastAudio { 61 | pub main_song: Song, 62 | pub dj: UserProfile, 63 | pub liked_count: usize, 64 | pub comment_count: usize, 65 | } 66 | 67 | /// User created podcasts 68 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 69 | #[serde(rename_all = "camelCase")] 70 | pub struct UserPodcastsResp { 71 | pub code: usize, 72 | #[serde(default)] 73 | pub dj_radios: Vec, 74 | #[serde(default)] 75 | pub has_more: bool, 76 | } 77 | 78 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 79 | #[serde(rename_all = "camelCase")] 80 | pub struct PodcastAudiosResp { 81 | pub code: usize, 82 | #[serde(default)] 83 | pub programs: Vec, 84 | #[serde(default)] 85 | pub more: bool, 86 | } 87 | 88 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 89 | #[serde(rename_all = "camelCase")] 90 | pub struct UserProfile { 91 | pub user_id: usize, 92 | pub nickname: String, 93 | } 94 | 95 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 96 | #[serde(rename_all = "camelCase")] 97 | pub struct UserAccountResp { 98 | pub code: usize, 99 | pub profile: Option, 100 | } 101 | 102 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 103 | pub struct UserPlaylistResp { 104 | pub code: usize, 105 | #[serde(default)] 106 | pub playlist: Vec, 107 | } 108 | 109 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 110 | pub struct PlaylistDetailResp { 111 | pub code: usize, 112 | pub playlist: Option, 113 | } 114 | 115 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct Playlist { 118 | pub id: usize, 119 | pub name: String, 120 | pub description: Option, 121 | } 122 | 123 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 124 | #[serde(rename_all = "camelCase")] 125 | pub struct PlaylistDetail { 126 | pub id: usize, 127 | pub name: String, 128 | pub description: Option, 129 | #[serde(default)] 130 | pub tracks: Vec, 131 | #[serde(default)] 132 | pub track_ids: Vec, 133 | pub user_id: usize, 134 | } 135 | 136 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 137 | pub struct Id { 138 | pub id: usize, 139 | } 140 | 141 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 142 | pub struct SongUrlResp { 143 | pub code: usize, 144 | #[serde(default)] 145 | pub data: Vec, 146 | } 147 | 148 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 149 | pub struct SongUrl { 150 | pub id: usize, 151 | pub url: String, 152 | pub br: usize, 153 | } 154 | 155 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 156 | #[serde(rename_all = "camelCase")] 157 | pub struct UserCloudResp { 158 | pub code: usize, 159 | #[serde(default)] 160 | pub has_more: bool, 161 | #[serde(default)] 162 | pub data: Vec, 163 | } 164 | 165 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 166 | #[serde(rename_all = "camelCase")] 167 | pub struct CloudSongMeta { 168 | pub simple_song: Song, 169 | pub song_id: usize, 170 | pub song_name: String, 171 | pub add_time: i128, 172 | pub file_size: usize, 173 | pub bitrate: usize, 174 | pub file_name: String, 175 | } 176 | 177 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 178 | #[serde(rename_all = "camelCase")] 179 | pub struct RecommendedSongs { 180 | #[serde(default)] 181 | pub daily_songs: Vec, 182 | #[serde(default)] 183 | pub order_songs: Vec, 184 | } 185 | 186 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 187 | pub struct RecommendedSongsResp { 188 | pub code: usize, 189 | pub data: RecommendedSongs, 190 | } 191 | 192 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 193 | #[serde(rename_all = "camelCase")] 194 | pub struct Comment { 195 | pub user: UserProfile, 196 | #[serde(default)] 197 | pub content: String, 198 | pub time: u64, 199 | pub liked_count: usize, 200 | pub liked: bool, 201 | } 202 | 203 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 204 | #[serde(rename_all = "camelCase")] 205 | pub struct ResourceComments { 206 | #[serde(default)] 207 | pub comments: Vec, 208 | pub total_count: usize, 209 | pub has_more: bool, 210 | } 211 | 212 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 213 | #[serde(rename_all = "camelCase")] 214 | pub struct ResourceCommentsResp { 215 | pub code: usize, 216 | pub data: ResourceComments, 217 | } 218 | 219 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 220 | #[serde(rename_all = "camelCase")] 221 | pub struct HotCommentsResp { 222 | pub code: usize, 223 | #[serde(default)] 224 | pub hot_comments: Vec, 225 | pub has_more: bool, 226 | pub total: usize, 227 | } 228 | 229 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 230 | #[serde(rename_all = "camelCase")] 231 | pub struct LyricResp { 232 | pub code: usize, 233 | pub sgc: bool, 234 | pub sfy: bool, 235 | pub qfy: bool, 236 | pub lrc: Option, 237 | pub klyric: Option, 238 | pub tlyric: Option, 239 | } 240 | 241 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 242 | #[serde(rename_all = "camelCase")] 243 | pub struct Lyric { 244 | #[serde(default)] 245 | pub version: usize, 246 | #[serde(default)] 247 | pub lyric: String, 248 | } 249 | 250 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 251 | #[serde(rename_all = "camelCase")] 252 | pub struct PersonalFmResp { 253 | pub code: usize, 254 | #[serde(default)] 255 | pub data: Vec, 256 | } 257 | 258 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 259 | #[serde(rename_all = "camelCase")] 260 | pub struct RecommendedPlaylistsResp { 261 | pub code: usize, 262 | #[serde(default)] 263 | pub recommend: Vec, 264 | } 265 | 266 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 267 | #[serde(rename_all = "camelCase")] 268 | pub struct SimiSongsResp { 269 | pub code: usize, 270 | #[serde(default)] 271 | pub songs: Vec, 272 | } 273 | 274 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 275 | #[serde(rename_all = "camelCase")] 276 | pub struct ArtistSongsResp { 277 | pub code: usize, 278 | #[serde(default)] 279 | pub songs: Vec, 280 | #[serde(default)] 281 | pub more: bool, 282 | #[serde(default)] 283 | pub total: usize, 284 | } 285 | 286 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 287 | #[serde(rename_all = "camelCase")] 288 | pub struct ArtistSublistResp { 289 | pub code: usize, 290 | #[serde(default)] 291 | pub data: Vec, 292 | #[serde(default)] 293 | pub has_more: bool, 294 | } 295 | 296 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 297 | #[serde(rename_all = "camelCase")] 298 | pub struct Podcast { 299 | pub id: usize, 300 | pub name: String, 301 | pub desc: String, 302 | pub sub_count: usize, 303 | pub category: String, 304 | pub dj: UserProfile, 305 | } 306 | 307 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 308 | #[serde(rename_all = "camelCase")] 309 | pub struct SearchResultArtist { 310 | #[serde(default)] 311 | pub artists: Vec, 312 | } 313 | 314 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 315 | #[serde(rename_all = "camelCase")] 316 | pub struct SearchResultPodcast { 317 | #[serde(default)] 318 | pub dj_radios: Vec, 319 | } 320 | 321 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 322 | #[serde(rename_all = "camelCase")] 323 | pub struct SearchResultPlaylist { 324 | #[serde(default)] 325 | pub playlists: Vec, 326 | } 327 | 328 | #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] 329 | #[serde(rename_all = "camelCase")] 330 | pub struct SearchResultAlbum { 331 | #[serde(default)] 332 | pub albums: Vec, 333 | } 334 | 335 | #[cfg(test)] 336 | mod tests { 337 | 338 | use serde_json::json; 339 | 340 | use crate::{ 341 | types::{ 342 | ArtistSongsResp, ArtistSublistResp, HotCommentsResp, LyricResp, PersonalFmResp, 343 | PlaylistDetailResp, PodcastAudiosResp, RecommendedPlaylistsResp, RecommendedSongsResp, 344 | ResourceCommentsResp, SearchAlbumResp, SearchArtistResp, SearchPlaylistResp, 345 | SearchPodcastResp, SearchSongResp, SimiSongsResp, SongUrlResp, UserAccountResp, 346 | UserCloudResp, UserPlaylistResp, UserPodcastsResp, 347 | }, 348 | NcmApi, SearchType, 349 | }; 350 | 351 | // let res = resp.unwrap(); 352 | // let mut f = std::fs::OpenOptions::new() 353 | // .create(true) 354 | // .write(true) 355 | // .open("test-data/search_podcast.json") 356 | // .unwrap(); 357 | // f.write_all(res.data()).unwrap(); 358 | 359 | #[tokio::test] 360 | async fn test_de_search_song() { 361 | let api = NcmApi::default(); 362 | let resp = api.search("xusong", None).await; 363 | assert!(resp.is_ok()); 364 | 365 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 366 | assert_eq!(res.code, 200); 367 | } 368 | 369 | #[tokio::test] 370 | async fn test_de_search_artist() { 371 | let api = NcmApi::default(); 372 | let resp = api 373 | .search("xusong", Some(json!({ "type": SearchType::Artist }))) 374 | .await; 375 | assert!(resp.is_ok()); 376 | 377 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 378 | assert_eq!(res.code, 200); 379 | } 380 | 381 | #[tokio::test] 382 | async fn test_de_search_playlist() { 383 | let api = NcmApi::default(); 384 | let resp = api 385 | .search("ost", Some(json!({ "type": SearchType::Collection }))) 386 | .await; 387 | assert!(resp.is_ok()); 388 | 389 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 390 | assert_eq!(res.code, 200); 391 | } 392 | 393 | #[tokio::test] 394 | async fn test_de_search_podcast() { 395 | let api = NcmApi::default(); 396 | let resp = api 397 | .search("asmr", Some(json!({ "type": SearchType::Podcast }))) 398 | .await; 399 | assert!(resp.is_ok()); 400 | 401 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 402 | assert_eq!(res.code, 200); 403 | } 404 | 405 | #[tokio::test] 406 | async fn test_de_search_album() { 407 | let api = NcmApi::default(); 408 | let resp = api 409 | .search("Mota", Some(json!({ "type": SearchType::Album }))) 410 | .await; 411 | assert!(resp.is_ok()); 412 | 413 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 414 | assert_eq!(res.code, 200); 415 | } 416 | 417 | #[tokio::test] 418 | async fn test_de_user_account() { 419 | let api = NcmApi::default(); 420 | let resp = api.user_account().await; 421 | assert!(resp.is_ok()); 422 | 423 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 424 | assert_eq!(res.code, 200); 425 | } 426 | 427 | #[tokio::test] 428 | async fn test_de_user_playlist() { 429 | let api = NcmApi::default(); 430 | let uid = 49668844; 431 | let resp = api.user_playlist(uid, None).await; 432 | assert!(resp.is_ok()); 433 | 434 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 435 | assert_eq!(res.code, 200); 436 | } 437 | 438 | #[tokio::test] 439 | async fn test_de_playlist_detail() { 440 | let api = NcmApi::default(); 441 | let resp = api.playlist_detail(6591923961, None).await; 442 | assert!(resp.is_ok()); 443 | 444 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 445 | assert_eq!(res.code, 200); 446 | } 447 | 448 | #[tokio::test] 449 | async fn test_de_song_url() { 450 | let api = NcmApi::default(); 451 | let resp = api.song_url(&vec![28497094, 28497093]).await; 452 | assert!(resp.is_ok()); 453 | 454 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 455 | assert_eq!(res.code, 200); 456 | } 457 | 458 | #[tokio::test] 459 | async fn test_de_user_cloud() { 460 | let api = NcmApi::default(); 461 | let resp = api.user_cloud(None).await; 462 | assert!(resp.is_ok()); 463 | 464 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 465 | assert_eq!(res.code, 200); 466 | } 467 | 468 | #[tokio::test] 469 | async fn test_de_recommended_songs() { 470 | let api = NcmApi::default(); 471 | let resp = api.recommend_songs().await; 472 | assert!(resp.is_ok()); 473 | 474 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 475 | assert_eq!(res.code, 200); 476 | } 477 | 478 | #[tokio::test] 479 | async fn test_de_comments() { 480 | let api = NcmApi::default(); 481 | let resp = api 482 | .comment(32977061, crate::api::ResourceType::Song, 10, 1, 1, 0, false) 483 | .await; 484 | assert!(resp.is_ok()); 485 | 486 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 487 | assert_eq!(res.code, 200); 488 | } 489 | 490 | #[tokio::test] 491 | async fn test_de_hot_comments() { 492 | let api = NcmApi::default(); 493 | let resp = api 494 | .comment_hot(32977061, crate::ResourceType::Song, None) 495 | .await; 496 | assert!(resp.is_ok()); 497 | 498 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 499 | assert_eq!(res.code, 200); 500 | } 501 | 502 | #[tokio::test] 503 | async fn test_de_lyric() { 504 | let api = NcmApi::default(); 505 | let resp = api.lyric(17346999).await; 506 | assert!(resp.is_ok()); 507 | 508 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 509 | assert_eq!(res.code, 200); 510 | } 511 | 512 | #[tokio::test] 513 | async fn test_de_personal_fm() { 514 | let api = NcmApi::default(); 515 | let resp = api.personal_fm().await; 516 | assert!(resp.is_ok()); 517 | 518 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 519 | assert_eq!(res.code, 200); 520 | } 521 | 522 | #[tokio::test] 523 | async fn test_de_recommended_playlists() { 524 | let api = NcmApi::default(); 525 | let resp = api.recommend_resource().await; 526 | assert!(resp.is_ok()); 527 | 528 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 529 | assert_eq!(res.code, 200); 530 | } 531 | 532 | #[tokio::test] 533 | async fn test_de_simi_songs() { 534 | let api = NcmApi::default(); 535 | let resp = api.simi_song(347230, None).await; 536 | assert!(resp.is_ok()); 537 | 538 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 539 | assert_eq!(res.code, 200); 540 | } 541 | 542 | #[tokio::test] 543 | async fn test_de_artist_songs() { 544 | let api = NcmApi::default(); 545 | let resp = api.artist_songs(6452, None).await; 546 | assert!(resp.is_ok()); 547 | 548 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 549 | assert_eq!(res.code, 200); 550 | } 551 | 552 | #[tokio::test] 553 | async fn test_de_artist_sublist() { 554 | let api = NcmApi::default(); 555 | let resp = api.artist_sublist(None).await; 556 | assert!(resp.is_ok()); 557 | 558 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 559 | assert_eq!(res.code, 200); 560 | } 561 | 562 | #[tokio::test] 563 | async fn test_de_user_podcast() { 564 | let api = NcmApi::default(); 565 | let resp = api.user_podcast(1398995370).await; 566 | assert!(resp.is_ok()); 567 | 568 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 569 | assert_eq!(res.code, 200); 570 | } 571 | 572 | #[tokio::test] 573 | async fn test_de_podcast_audio() { 574 | let api = NcmApi::default(); 575 | let resp = api.podcast_audio(965114264, None).await; 576 | assert!(resp.is_ok()); 577 | 578 | let res = serde_json::from_slice::(resp.unwrap().data()).unwrap(); 579 | assert_eq!(res.code, 200); 580 | } 581 | } 582 | --------------------------------------------------------------------------------