use std::time::Duration;
use anyhow::{Context, Result};
use futures::future::try_join_all;
use serde_json::{Value, json};
use tracing::{debug, warn};
use super::Session;
use super::api::random_searchid;
use super::song::SongRef;
use crate::model::{Song, SourceKind};
const MODULE: &str = "music.search.SearchCgiService";
const METHOD: &str = "DoSearchForQQMusicMobile";
pub const PAGE_SIZE: usize = 10;
const EMPTY_RETRIES: usize = 3;
pub fn cover_url(album_mid: &str) -> Option<String> {
(!album_mid.is_empty())
.then(|| format!("https://y.gtimg.cn/music/photo_new/T002R800x800M000{album_mid}.jpg"))
}
pub fn song_from_item(item: &Value) -> Option<Song> {
let mid = item.get("mid").and_then(Value::as_str)?.to_string();
if mid.is_empty() {
return None;
}
let s = |v: Option<&Value>| v.and_then(Value::as_str).map(clean_text);
let title = s(item.get("title"))
.filter(|t| !t.is_empty())
.or_else(|| s(item.get("name")))
.unwrap_or_default();
let singers = item
.get("singer")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|x| s(x.get("name")))
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let album_v = item.get("album");
let album = s(album_v.and_then(|a| a.get("name")))
.filter(|t| !t.is_empty())
.or_else(|| s(album_v.and_then(|a| a.get("title"))))
.unwrap_or_default();
let album_mid = s(album_v.and_then(|a| a.get("mid"))).unwrap_or_default();
let file = item.get("file");
let size = |k: &str| {
file.and_then(|f| f.get(k))
.and_then(Value::as_u64)
.unwrap_or(0)
};
Some(Song {
source: SourceKind::Qq,
id: mid,
title,
singers,
album,
cover_url: cover_url(&album_mid),
album_id: album_mid,
interval: item.get("interval").and_then(Value::as_u64).unwrap_or(0),
size_flac: size("size_flac"),
size_320mp3: size("size_320mp3"),
size_hires: size("size_hires"),
track: None,
})
}
pub fn clean_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
.replace("&", "&")
.trim()
.to_string()
}
pub fn plan_pages(limit: usize, page_size: usize) -> Vec<(usize, usize)> {
let page_size = page_size.max(1);
(0..limit.div_ceil(page_size))
.map(|i| (i + 1, page_size))
.collect()
}
#[derive(Debug, Clone, Copy)]
pub enum SearchType {
Song,
Singer,
Album,
}
impl SearchType {
fn code(self) -> u32 {
match self {
SearchType::Song => 0,
SearchType::Singer => 1,
SearchType::Album => 2,
}
}
fn items_key(self) -> &'static str {
match self {
SearchType::Song => "item_song",
SearchType::Singer => "singer",
SearchType::Album => "item_album",
}
}
}
fn parse_items(node: &Value, ty: SearchType) -> Vec<Value> {
node.pointer(&format!("/data/body/{}", ty.items_key()))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
pub async fn search_page(
session: &Session,
keyword: &str,
ty: SearchType,
page_num: usize,
page_size: usize,
) -> Result<Vec<Value>> {
for attempt in 1..=EMPTY_RETRIES {
let param = json!({
"searchid": random_searchid(),
"query": keyword,
"search_type": ty.code(),
"num_per_page": page_size,
"page_num": page_num,
"highlight": 1,
"grp": 1,
});
let qimei36 = session.qimei36().await;
let node = session
.call(&qimei36, "11", MODULE, METHOD, param)
.await
.with_context(|| format!("搜索第 {page_num} 页失败"))?;
let items = parse_items(&node, ty);
if !items.is_empty() {
debug!(page_num, count = items.len(), "搜索页完成");
return Ok(items);
}
if attempt < EMPTY_RETRIES {
debug!(page_num, attempt, "搜索返回空结果,更换 QIMEI36 后重试");
session.refresh_qimei().await;
tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
}
}
warn!(page_num, "多次重试后搜索结果仍为空");
Ok(Vec::new())
}
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<Song>> {
let pages = plan_pages(limit, PAGE_SIZE.min(limit.max(1)));
let results = try_join_all(
pages
.iter()
.map(|&(page, size)| search_page(session, keyword, SearchType::Song, page, size)),
)
.await?;
let mut seen = std::collections::HashSet::new();
Ok(results
.iter()
.flatten()
.filter_map(song_from_item)
.filter(|s| seen.insert(s.id.clone()))
.take(limit)
.collect())
}
pub async fn song_detail(session: &Session, song: &SongRef) -> Result<Song> {
let param = match song {
SongRef::Mid(mid) => json!({ "song_mid": mid }),
SongRef::Id(id) => json!({ "song_id": id }),
};
let qimei36 = session.qimei36().await;
let node = session
.call(
&qimei36,
"11",
"music.pf_song_detail_svr",
"get_song_detail_yqq",
param,
)
.await
.with_context(|| format!("查询歌曲 {song} 失败(可能不存在)"))?;
node.pointer("/data/track_info")
.and_then(song_from_item)
.with_context(|| format!("歌曲 {song} 不存在"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_highlight_tags() {
assert_eq!(clean_text("<em>十一月的萧邦</em>"), "十一月的萧邦");
assert_eq!(clean_text("A & B"), "A & B");
assert_eq!(clean_text(" 夜曲 "), "夜曲");
assert_eq!(clean_text("a<b"), "a");
}
#[test]
fn pages() {
assert_eq!(plan_pages(10, 10), vec![(1, 10)]);
assert_eq!(plan_pages(20, 10), vec![(1, 10), (2, 10)]);
assert_eq!(plan_pages(25, 10), vec![(1, 10), (2, 10), (3, 10)]);
assert_eq!(plan_pages(3, 3), vec![(1, 3)]);
assert!(plan_pages(0, 10).is_empty());
}
#[test]
fn parse_item() {
let item = json!({
"mid": "001zMQr71F1Qo8", "title": "夜曲", "name": "夜曲", "interval": 226,
"singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}, {"name": "Lara梁心颐"}],
"album": {"mid": "0024bjiL2aocxT", "name": "十一月的萧邦", "title": "<em>十一月的萧邦</em>"},
"file": {"media_mid": "0024jrso28p8VA", "size_flac": 26691277, "size_320mp3": 9075745, "size_hires": 0}
});
let s = song_from_item(&item).unwrap();
assert_eq!(
(s.id.as_str(), s.source),
("001zMQr71F1Qo8", SourceKind::Qq)
);
assert_eq!(s.title, "夜曲");
assert_eq!(s.singer_text(), "周杰伦, Lara梁心颐");
assert_eq!(s.album, "十一月的萧邦");
assert_eq!(s.interval, 226);
assert_eq!(s.size_flac, 26691277);
assert_eq!(
s.cover_url.as_deref().unwrap(),
"https://y.gtimg.cn/music/photo_new/T002R800x800M0000024bjiL2aocxT.jpg"
);
}
#[test]
fn parse_item_title_fallback_and_missing_mid() {
let s = song_from_item(&json!({"mid": "x", "title": "", "name": "<em>晴天</em>"})).unwrap();
assert_eq!(s.title, "晴天");
assert!(s.cover_url.is_none());
assert!(song_from_item(&json!({"title": "无 mid"})).is_none());
}
#[test]
fn parse_body() {
let node =
json!({"data": {"body": {"item_song": [{"mid": "a"}, {"mid": ""}, {"mid": "b"}]}}});
let songs: Vec<Song> = parse_items(&node, SearchType::Song)
.iter()
.filter_map(song_from_item)
.collect();
assert_eq!(
songs.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
assert!(parse_items(&json!({}), SearchType::Song).is_empty());
let albums = json!({"data": {"body": {"item_album": [{"albummid": "x"}]}}});
assert_eq!(parse_items(&albums, SearchType::Album).len(), 1);
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::Config;
pub fn session() -> Session {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap();
Session::new(client, Config::default())
}
#[tokio::test]
#[ignore = "需要网络"]
async fn search_real() {
let songs = search(&session(), "十一月的萧邦", 15).await.unwrap();
assert_eq!(songs.len(), 15);
let yequ = songs.iter().find(|s| s.id == "001zMQr71F1Qo8").unwrap();
assert_eq!(yequ.title, "夜曲");
assert_eq!(yequ.album, "十一月的萧邦");
assert!(yequ.singers.contains(&"周杰伦".to_string()));
}
}