pub mod format;
pub mod identifier;
pub mod typing;
use std::borrow::Cow;
use http::header::{COOKIE, ORIGIN, REFERER};
use http::{HeaderMap, HeaderValue};
use log::{debug, trace};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::Url;
use serde::Deserialize;
use serde_json::json;
use thiserror::Error;
use unm_request::json::{Json, UnableToExtractJson};
use unm_request::{build_client, RequestModuleError};
use unm_types::Context;
use self::format::QQFormat;
use self::identifier::QQResourceIdentifier;
use self::typing::QQSongData;
use crate::api::typing::QQSingleResponseRoot;
pub async fn search_by_keyword(keyword: &str, ctx: &Context) -> QQApiModuleResult<QQSongData> {
debug!("Searching “{keyword}” in QQ Music…");
let url = construct_search_url(keyword)?;
let cookie = extract_cookie(ctx);
let client = build_client(ctx.proxy_uri.as_deref())?;
let mut request = client
.get(url)
.header(ORIGIN, HeaderValue::from_static("http://y.qq.com"))
.header(REFERER, HeaderValue::from_static("http://y.qq.com"));
if let Some(cookie) = cookie {
request = request.header(COOKIE, HeaderValue::from_str(cookie)?);
}
let response = request
.send()
.await
.map_err(QQApiModuleError::RequestFailed)?;
let json = response
.json::<Json>()
.await
.map_err(QQApiModuleError::ResponseJsonDeserializeFailed)?;
let data = QQSongData::deserialize(json.pointer("/search/data/body/song").ok_or(
UnableToExtractJson {
json_pointer: "/search/data/body/song",
expected_type: "QQSongData",
},
)?)
.map_err(QQApiModuleError::JsonDeserializeFailed)?;
Ok(data)
}
pub async fn retrieve_single(
identifier: &str,
ctx: &Context,
) -> QQApiModuleResult<QQSingleResponseRoot> {
debug!("Retrieving the song URL of “{identifier}” from QQ Music…");
let identifier = QQResourceIdentifier::deserialize(identifier)?;
let mode = QQFormat::from_context(ctx);
let cookie = extract_cookie(ctx);
let client = build_client(ctx.proxy_uri.as_deref())?;
let url = construct_single_url(&identifier, mode, ctx)?;
let response = client
.get(url)
.headers(construct_header(cookie)?)
.send()
.await
.map_err(QQApiModuleError::RequestFailed)?;
let json = response
.json::<Json>()
.await
.map_err(QQApiModuleError::ResponseJsonDeserializeFailed)?;
let data = json
.pointer("/req_0")
.and_then(|data| QQSingleResponseRoot::deserialize(data).ok())
.ok_or(UnableToExtractJson {
json_pointer: "/req_0",
expected_type: "QQSingleResponseRoot",
})?;
Ok(data)
}
fn extract_cookie(ctx: &Context) -> Option<&str> {
trace!("Extracting cookie from ConfigManager…");
if let Some(ref config) = ctx.config {
config.get_deref(Cow::Borrowed("qq:cookie"))
} else {
None
}
}
fn construct_header(cookie: Option<&str>) -> QQApiModuleResult<HeaderMap> {
trace!("Constructing header with cookie…");
let mut hm = HeaderMap::with_capacity(3);
hm.insert(ORIGIN, HeaderValue::from_static("http://y.qq.com"));
hm.insert(REFERER, HeaderValue::from_static("http://y.qq.com"));
if let Some(cookie) = cookie {
if !cookie.is_empty() {
hm.insert(COOKIE, HeaderValue::from_str(cookie)?);
}
}
Ok(hm)
}
fn construct_search_url(keyword: &str) -> QQApiModuleResult<Url> {
trace!("Constructing search URL with parameters…");
let data = json!({
"search": {
"method": "DoSearchForQQMusicDesktop",
"module": "music.search.SearchCgiService",
"param": {
"num_per_page": 5,
"page_num": 1,
"query": keyword,
"search_type": 0
}
}
});
Ok(Url::parse_with_params(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
&[("data", data.to_string())],
)?)
}
fn construct_single_url(
ident: &QQResourceIdentifier,
format: QQFormat,
ctx: &Context,
) -> QQApiModuleResult<Url> {
trace!("Constructing single URL with parameters…");
static REGEX_EXTRACT_COOKIE_UIN: Lazy<Regex> = Lazy::new(|| Regex::new(r"uin=(\d+)").unwrap());
let cookie = extract_cookie(ctx).unwrap_or("");
let uin_value = REGEX_EXTRACT_COOKIE_UIN
.captures(cookie)
.and_then(|v| v.get(1).map(|v| v.as_str()))
.unwrap_or("0");
let param = json!({
"guid": fastrand::i32(1..10000000),
"loginflag": 1u8,
"filename": [
format.to_filename(ident.file),
],
"songmid": [
&ident.mid,
],
"songtype": [0u8],
"uin": uin_value,
"platform": "20",
});
let parameter = json!({
"req_0": {
"module": "vkey.GetVkeyServer",
"method": "CgiGetVkey",
"param": param,
},
});
Ok(Url::parse_with_params(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
[(
"data",
serde_json::to_string(¶meter).map_err(QQApiModuleError::JsonSerializeFailed)?,
)],
)?)
}
#[derive(Debug, Error)]
pub enum QQApiModuleError {
#[error("failed to send request: {0}")]
RequestFailed(reqwest::Error),
#[error("failed to deserialize the response JSON: {0}")]
ResponseJsonDeserializeFailed(reqwest::Error),
#[error("invalid header value: {0}")]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error("failed to construct URL: {0}")]
ConstructUrlFailed(#[from] url::ParseError),
#[error("failed to serialize as JSON string: {0}")]
JsonSerializeFailed(serde_json::Error),
#[error("failed to deserialize to a structured data: {0}")]
JsonDeserializeFailed(serde_json::Error),
#[error("something wrong in request module: {0}")]
RequestModuleError(#[from] RequestModuleError),
#[error("failed to deserialize QQResourceIdentifier: {0}")]
QQResourceIdentifierDeserializationFailed(#[from] identifier::DeserializationFailed),
#[error("unable to extract such a JSON pointer: {0}")]
NoSuchField(#[from] UnableToExtractJson<'static>),
}
pub type QQApiModuleResult<T> = Result<T, QQApiModuleError>;