use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use tracing::debug;
use super::search::{SearchType, clean_text, search_page};
use super::{Session, is_qq_host};
use crate::model::{AlbumKind, AlbumOrder, ArtistAlbum, SingerHit};
const ALBUMS_MODULE: &str = "music.musichallAlbum.AlbumListServer";
const ALBUMS_METHOD: &str = "GetAlbumList";
const ALBUMS_PAGE: u64 = 80;
const MAX_PAGES: u64 = 50;
impl SingerHit {
pub fn from_item(item: &Value) -> Option<SingerHit> {
let mid = item
.get("singerMID")
.and_then(Value::as_str)
.filter(|m| !m.is_empty())?
.to_string();
let n = |k: &str| item.get(k).and_then(Value::as_u64).unwrap_or(0);
Some(SingerHit {
id: mid,
name: item
.get("singerName")
.and_then(Value::as_str)
.map(clean_text)
.unwrap_or_default(),
album_num: n("albumNum"),
song_num: n("songNum"),
})
}
}
impl AlbumKind {
pub fn from_label(s: &str) -> AlbumKind {
match s.trim() {
"录音室专辑" => AlbumKind::Studio,
"EP" => AlbumKind::Ep,
"Single" => AlbumKind::Single,
"演唱会" => AlbumKind::Live,
_ => AlbumKind::Other,
}
}
}
impl ArtistAlbum {
pub fn from_item(item: &Value) -> Option<ArtistAlbum> {
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 album_type = s("albumType").unwrap_or_default();
Some(ArtistAlbum {
id: mid,
name: s("albumName").unwrap_or_default(),
singer: s("singerName").unwrap_or_default().replace('/', ", "),
kind: AlbumKind::from_label(&album_type),
album_type,
publish_date: s("publishDate").unwrap_or_default(),
song_num: item.get("totalNum").and_then(Value::as_u64).unwrap_or(0),
})
}
}
impl AlbumOrder {
fn code(self) -> u32 {
match self {
AlbumOrder::New => 0,
AlbumOrder::Hot => 1,
}
}
}
fn is_mid(s: &str) -> bool {
s.len() == 14 && s.bytes().all(|b| b.is_ascii_alphanumeric())
}
pub fn parse_mid_arg(s: &str) -> Option<String> {
let s = s.trim();
is_mid(s).then(|| s.to_string())
}
pub fn parse_link(input: &str) -> Option<String> {
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, "singermid" | "singerMid" | "singer_mid")
&& is_mid(v)
{
return Some(v.to_string());
}
}
let segs: Vec<&str> = path.trim_end_matches('/').split('/').collect();
let pos = segs.iter().position(|s| *s == "singer")?;
let token = segs.get(pos + 1)?;
let token = token.trim_end_matches(".html").trim_end_matches(".htm");
is_mid(token).then(|| token.to_string())
}
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<SingerHit>> {
let items = search_page(session, keyword, SearchType::Singer, 1, limit.max(1)).await?;
let mut seen = std::collections::HashSet::new();
Ok(items
.iter()
.filter_map(SingerHit::from_item)
.filter(|s| seen.insert(s.id.clone()))
.take(limit)
.collect())
}
fn parse_album_page(node: &Value) -> (Vec<ArtistAlbum>, u64) {
let albums = node
.pointer("/data/albumList")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(ArtistAlbum::from_item).collect())
.unwrap_or_default();
let total = node
.pointer("/data/total")
.and_then(Value::as_u64)
.unwrap_or(0);
(albums, total)
}
pub async fn albums(
session: &Session,
singer_mid: &str,
order: AlbumOrder,
) -> Result<Vec<ArtistAlbum>> {
let mut all: Vec<ArtistAlbum> = Vec::new();
let mut begin = 0;
for _ in 0..MAX_PAGES {
let qimei36 = session.qimei36().await;
let node = session
.call(
&qimei36,
"11",
ALBUMS_MODULE,
ALBUMS_METHOD,
json!({
"singerMid": singer_mid,
"singerID": 0,
"order": order.code(),
"begin": begin,
"num": ALBUMS_PAGE,
"songNumTag": 1,
}),
)
.await
.context("获取歌手专辑列表失败(歌手可能不存在)")?;
let (page, total) = parse_album_page(&node);
debug!(begin, got = page.len(), total, "歌手专辑页");
if page.is_empty() {
break;
}
begin += page.len() as u64;
all.extend(page);
if begin >= total {
break;
}
}
let mut seen = std::collections::HashSet::new();
all.retain(|a| seen.insert(a.id.clone()));
if all.is_empty() {
bail!("歌手 {singer_mid} 没有专辑");
}
Ok(all)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn links() {
let mid = Some("0025NhlN2yWrP4".to_string());
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/singer/0025NhlN2yWrP4"),
mid
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/singer/0025NhlN2yWrP4/"),
mid
);
assert_eq!(
parse_link("https://y.qq.com/n/yqq/singer/0025NhlN2yWrP4.html#stat=x"),
mid
);
assert_eq!(
parse_link(
"https://i.y.qq.com/n2/m/share/details/singer.html?singermid=0025NhlN2yWrP4"
),
mid
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
None
);
assert_eq!(
parse_link("https://evil.com/n/ryqq/singer/0025NhlN2yWrP4"),
None
);
assert_eq!(parse_link("周杰伦"), None);
assert_eq!(
parse_mid_arg(" 0025NhlN2yWrP4 "),
Some("0025NhlN2yWrP4".into())
);
assert_eq!(parse_mid_arg("4558"), None);
}
#[test]
fn parse_singer_hit() {
let item = json!({"albumNum": 43, "singerID": 4558, "singerMID": "0025NhlN2yWrP4",
"singerName": "周杰伦", "singerName_hilight": "<em>周杰伦</em>", "songNum": 1012});
let h = SingerHit::from_item(&item).unwrap();
assert_eq!(
(h.id.as_str(), h.name.as_str(), h.album_num, h.song_num),
("0025NhlN2yWrP4", "周杰伦", 43, 1012)
);
assert!(SingerHit::from_item(&json!({"singerName": "无 mid"})).is_none());
}
#[test]
fn parse_albums() {
let node = json!({"data": {"singerMid": "0025NhlN2yWrP4", "total": 43, "albumList": [
{"albumMid": "0024bjiL2aocxT", "albumName": "十一月的萧邦", "publishDate": "2005-11-01",
"totalNum": 12, "albumType": "录音室专辑", "albumID": 8220, "singerName": "周杰伦"},
{"albumMid": "004Bux530GytNj", "albumName": "恒星不忘 Forever Forever",
"publishDate": "2025-12-05", "totalNum": 1, "albumType": "Single",
"singerName": "周杰伦/言承旭/吴建豪/周渝民/五月天 阿信"},
{"albumName": "缺 mid"}
]}});
let (albums, total) = parse_album_page(&node);
assert_eq!(total, 43);
assert_eq!(albums.len(), 2);
let a = &albums[0];
assert_eq!(
(a.name.as_str(), a.kind, a.song_num, a.year()),
("十一月的萧邦", AlbumKind::Studio, 12, "2005")
);
assert_eq!(albums[1].kind, AlbumKind::Single);
assert_eq!(
albums[1].singer,
"周杰伦, 言承旭, 吴建豪, 周渝民, 五月天 阿信"
);
let (empty, t) = parse_album_page(&json!({"data": {"albumList": [], "total": 0}}));
assert!(empty.is_empty() && t == 0);
}
#[test]
fn kinds() {
assert_eq!(AlbumKind::from_label("录音室专辑"), AlbumKind::Studio);
assert_eq!(AlbumKind::from_label("EP"), AlbumKind::Ep);
assert_eq!(AlbumKind::from_label("演唱会"), AlbumKind::Live);
assert_eq!(AlbumKind::from_label("人声音频"), AlbumKind::Other);
assert_eq!(AlbumKind::from_label(""), AlbumKind::Other);
}
}
#[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 artist_real() {
let s = session();
let hits = search(&s, "周杰伦", 5).await.unwrap();
let jay = crate::model::exact_match(&hits, "周杰伦").unwrap();
assert_eq!(jay.id, "0025NhlN2yWrP4");
let list = albums(&s, &jay.id, AlbumOrder::New).await.unwrap();
assert!(list.len() >= 43);
assert!(
list.iter()
.any(|a| a.id == "0024bjiL2aocxT" && a.song_num == 12)
);
let teresa = albums(&s, "000kfe022gdaAn", AlbumOrder::New).await.unwrap();
assert!(teresa.len() > 160);
assert!(albums(&s, "xxxxxxxxxxxxxx", AlbumOrder::New).await.is_err());
}
}