use anyhow::{Context, Result, bail};
use futures::future::try_join_all;
use serde_json::{Value, json};
use tracing::debug;
use super::search::{PAGE_SIZE, SearchType, clean_text, plan_pages, search_page, song_from_item};
use super::{Session, is_qq_host};
use crate::model::{Album, AlbumHit, Song, tracks_for};
const SONGS_MODULE: &str = "music.musichallAlbum.AlbumSongList";
const SONGS_METHOD: &str = "GetAlbumSongList";
const DETAIL_MODULE: &str = "music.musichallAlbum.AlbumInfoServer";
const DETAIL_METHOD: &str = "GetAlbumDetail";
const SONGS_PAGE: u32 = 100;
const MAX_PAGES: u32 = 50;
impl AlbumHit {
pub fn from_item(item: &Value) -> Option<AlbumHit> {
let s = |k: &str| item.get(k).and_then(Value::as_str).map(clean_text);
let mid = s("albummid").filter(|m| !m.is_empty())?;
let singer = item
.get("singer_list")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.get("name").and_then(Value::as_str).map(clean_text))
.filter(|n| !n.is_empty())
.collect::<Vec<_>>()
.join(", ")
})
.filter(|n| !n.is_empty())
.or_else(|| s("singer"))
.unwrap_or_default();
Some(AlbumHit {
id: mid,
name: s("name").unwrap_or_default(),
singer,
song_num: item.get("song_num").and_then(Value::as_u64).unwrap_or(0),
publish_date: s("publish_date").unwrap_or_default(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlbumRef {
Mid(String),
Id(u64),
}
fn is_mid(s: &str) -> bool {
s.len() == 14 && s.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn ref_from_token(s: &str) -> Option<AlbumRef> {
if is_mid(s) {
Some(AlbumRef::Mid(s.to_string()))
} else if !s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit()) {
s.parse().ok().map(AlbumRef::Id)
} else {
None
}
}
pub fn parse_mid_arg(s: &str) -> Option<AlbumRef> {
ref_from_token(s.trim())
}
pub fn parse_link(input: &str) -> Option<AlbumRef> {
let input = input.trim();
if !input.contains("://") || !is_qq_host(input) {
return None;
}
let (before_frag, _) = input.split_once('#').unwrap_or((input, ""));
let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=')
&& matches!(
k,
"albummid" | "albumMid" | "albumId" | "albumid" | "albumID"
)
&& let Some(r) = ref_from_token(v)
{
return Some(r);
}
}
let segs: Vec<&str> = path.trim_end_matches('/').split('/').collect();
let pos = segs
.iter()
.position(|s| s.eq_ignore_ascii_case("albumDetail") || *s == "album")?;
let token = segs.get(pos + 1)?;
ref_from_token(token.trim_end_matches(".html").trim_end_matches(".htm"))
}
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<AlbumHit>> {
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::Album, page, size)),
)
.await?;
let mut seen = std::collections::HashSet::new();
Ok(results
.iter()
.flatten()
.filter_map(AlbumHit::from_item)
.filter(|a| seen.insert(a.id.clone()))
.take(limit)
.collect())
}
fn parse_song_page(node: &Value) -> Vec<(Song, u32)> {
node.pointer("/data/songList")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(|x| {
let info = x.get("songInfo")?;
let cd = info.get("index_cd").and_then(Value::as_u64).unwrap_or(0) as u32;
song_from_item(info).map(|s| (s, cd))
})
.collect()
})
.unwrap_or_default()
}
async fn fetch_songs(session: &Session, r: &AlbumRef) -> Result<(String, Vec<Song>)> {
let (mid, id) = match r {
AlbumRef::Mid(m) => (m.clone(), 0),
AlbumRef::Id(i) => (String::new(), *i),
};
let mut found_mid = mid.clone();
let mut all: Vec<(Song, u32)> = Vec::new();
for page in 0..MAX_PAGES {
let begin = page * SONGS_PAGE;
let qimei36 = session.qimei36().await;
let node = session
.call(
&qimei36,
"11",
SONGS_MODULE,
SONGS_METHOD,
json!({"albumMid": mid, "albumID": id, "begin": begin, "num": SONGS_PAGE, "order": 2}),
)
.await
.context("获取专辑曲目失败(专辑可能不存在)")?;
if let Some(m) = node.pointer("/data/albumMid").and_then(Value::as_str)
&& !m.is_empty()
{
found_mid = m.to_string();
}
let total = node
.pointer("/data/totalNum")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let got = parse_song_page(&node);
debug!(begin, got = got.len(), total, "专辑曲目页");
let empty = got.is_empty();
all.extend(got);
if empty || all.len() >= total {
break;
}
}
let mut seen = std::collections::HashSet::new();
all.retain(|(s, _)| seen.insert(s.id.clone()));
let cds: Vec<u32> = all.iter().map(|(_, cd)| *cd).collect();
let songs = all
.into_iter()
.zip(tracks_for(&cds))
.map(|((mut s, _), t)| {
s.track = Some(t);
s
})
.collect();
Ok((found_mid, songs))
}
fn parse_detail(node: &Value) -> (String, Vec<String>, String) {
let s = |p: &str| {
node.pointer(p)
.and_then(Value::as_str)
.map(clean_text)
.unwrap_or_default()
};
let singers = node
.pointer("/data/singer/singerList")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.get("name").and_then(Value::as_str).map(clean_text))
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
(
s("/data/basicInfo/albumName"),
singers,
s("/data/basicInfo/publishDate"),
)
}
pub async fn fetch(session: &Session, r: &AlbumRef) -> Result<Album> {
let (mid, songs) = fetch_songs(session, r).await?;
if songs.is_empty() {
bail!("专辑没有可用曲目");
}
let qimei36 = session.qimei36().await;
let detail = session
.call(
&qimei36,
"11",
DETAIL_MODULE,
DETAIL_METHOD,
json!({ "albumMid": mid }),
)
.await;
let (mut name, mut singers, publish_date) = match detail {
Ok(node) => parse_detail(&node),
Err(e) => {
debug!("获取专辑详情失败,改用曲目信息: {e:#}");
Default::default()
}
};
if name.is_empty() {
name = songs[0].album.clone();
}
if singers.is_empty() {
singers = songs[0].singers.clone();
}
Ok(Album {
id: mid,
name,
singers,
publish_date,
songs,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn links() {
let mid = Some(AlbumRef::Mid("0024bjiL2aocxT".into()));
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
mid
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT/"),
mid
);
assert_eq!(
parse_link("https://y.qq.com/n/yqq/album/0024bjiL2aocxT.html#stat=x"),
mid
);
assert_eq!(
parse_link(
"https://i.y.qq.com/n2/m/share/details/album.html?albummid=0024bjiL2aocxT&x=1"
),
mid
);
assert_eq!(
parse_link("https://i.y.qq.com/n2/m/share/details/album.html?albumId=60671"),
Some(AlbumRef::Id(60671))
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/playlist/7039749142"),
None
);
assert_eq!(
parse_link("https://evil.com/albumDetail/0024bjiL2aocxT"),
None
);
assert_eq!(parse_link("十一月的萧邦"), None);
assert_eq!(parse_link("0024bjiL2aocxT"), None);
}
#[test]
fn mid_arg() {
assert_eq!(
parse_mid_arg(" 0024bjiL2aocxT "),
Some(AlbumRef::Mid("0024bjiL2aocxT".into()))
);
assert_eq!(parse_mid_arg("60671"), Some(AlbumRef::Id(60671)));
assert_eq!(parse_mid_arg("十一月的萧邦"), None);
assert_eq!(parse_mid_arg("0024bjiL2aoc"), None);
}
#[test]
fn tracks_single_disc() {
let t = tracks_for(&[0, 0, 0]);
assert_eq!(
t.iter()
.map(|t| (t.number, t.total, t.disc))
.collect::<Vec<_>>(),
[(1, 3, None), (2, 3, None), (3, 3, None)]
);
assert!(tracks_for(&[]).is_empty());
}
#[test]
fn tracks_multi_disc() {
let mut cds = vec![0; 21];
cds.extend(vec![1; 15]);
let t = tracks_for(&cds);
assert_eq!(t.len(), 36);
assert_eq!((t[0].number, t[0].total, t[0].disc), (1, 36, Some((1, 2))));
assert_eq!((t[21].number, t[21].disc), (22, Some((2, 2))));
assert_eq!(t[35].number, 36);
}
#[test]
fn hit_from_item() {
let item = json!({"albummid": "0024bjiL2aocxT", "name": "<em>十一月的萧邦</em>",
"singer": "周杰伦", "singer_list": [{"name": "周杰伦"}], "song_num": 12,
"publish_date": "2005-11-01", "id": 60671});
let h = AlbumHit::from_item(&item).unwrap();
assert_eq!(
(h.id.as_str(), h.name.as_str(), h.singer.as_str()),
("0024bjiL2aocxT", "十一月的萧邦", "周杰伦")
);
assert_eq!((h.song_num, h.publish_date.as_str()), (12, "2005-11-01"));
assert!(AlbumHit::from_item(&json!({"name": "无 mid"})).is_none());
}
#[test]
fn song_page_and_detail() {
let page = json!({"data": {"albumMid": "0024bjiL2aocxT", "totalNum": 2, "songList": [
{"songInfo": {"mid": "001zMQr71F1Qo8", "title": "夜曲", "index_cd": 0, "index_album": 1,
"singer": [{"name": "周杰伦"}], "album": {"mid": "0024bjiL2aocxT", "name": "十一月的萧邦"}}},
{"songInfo": {"mid": "", "title": "坏数据"}},
{"isThemeSong": 0}
]}});
let got = parse_song_page(&page);
assert_eq!(got.len(), 1);
assert_eq!((got[0].0.title.as_str(), got[0].1), ("夜曲", 0));
let detail = json!({"data": {"basicInfo": {"albumMid": "0024bjiL2aocxT", "albumName": "十一月的萧邦",
"publishDate": "2005-11-01"}, "singer": {"singerList": [{"name": "周杰伦"}]}}});
let (name, singers, date) = parse_detail(&detail);
assert_eq!(
(name.as_str(), date.as_str()),
("十一月的萧邦", "2005-11-01")
);
assert_eq!(singers, ["周杰伦"]);
}
#[test]
fn dir_name() {
let a = Album {
id: "m".into(),
name: "十一月的萧邦".into(),
singers: vec!["周杰伦".into()],
publish_date: String::new(),
songs: vec![],
};
assert_eq!(a.dir_name(), "周杰伦 - 十一月的萧邦");
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::Config;
use std::time::Duration;
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 album_real() {
let s = session();
let hits = search(&s, "十一月的萧邦", 5).await.unwrap();
assert!(
hits.iter()
.any(|h| h.id == "0024bjiL2aocxT" && h.singer == "周杰伦")
);
let a = fetch(&s, &AlbumRef::Mid("0024bjiL2aocxT".into()))
.await
.unwrap();
assert_eq!(
(a.name.as_str(), a.publish_date.as_str()),
("十一月的萧邦", "2005-11-01")
);
assert_eq!(a.singers, ["周杰伦"]);
assert_eq!(a.songs.len(), 12);
assert_eq!(a.songs[0].title, "夜曲");
assert_eq!(a.songs[11].track.unwrap().number, 12);
let b = fetch(&s, &AlbumRef::Id(60671)).await.unwrap();
assert_eq!(b.id, "0024bjiL2aocxT");
let c = fetch(&s, &AlbumRef::Mid("001oz4vo1MOXFk".into()))
.await
.unwrap();
assert_eq!(c.songs.len(), 36);
assert_eq!(c.songs[35].track.unwrap().disc, Some((2, 2)));
assert!(
fetch(&s, &AlbumRef::Mid("000000000000xx".into()))
.await
.is_err()
);
}
}