use anyhow::{Context, Result, bail};
use serde_json::Value;
use tracing::debug;
use super::search::{clean_text, cover_url};
use super::{Session, is_qq_host};
use crate::model::{Song, SourceKind};
const ENDPOINT: &str = "https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg";
#[derive(Debug)]
pub struct Playlist {
pub name: String,
pub songs: Vec<Song>,
}
fn is_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit())
}
pub fn parse_id(input: &str) -> Option<String> {
let input = input.trim();
if is_id(input) {
return Some(input.to_string());
}
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('=')
&& (k == "id" || k == "disstid")
&& is_id(v)
{
return Some(v.to_string());
}
}
let last = path.trim_end_matches('/').rsplit('/').next()?;
let last = last.trim_end_matches(".html").trim_end_matches(".htm");
is_id(last).then(|| last.to_string())
}
pub fn song_from_playlist_item(item: &Value) -> Option<Song> {
let s = |k: &str| item.get(k).and_then(Value::as_str).map(clean_text);
let mid = s("songmid").filter(|m| !m.is_empty())?;
let size = |k: &str| item.get(k).and_then(Value::as_u64).unwrap_or(0);
let album_mid = s("albummid").unwrap_or_default();
Some(Song {
source: SourceKind::Qq,
id: mid,
title: s("songname").unwrap_or_default(),
singers: item
.get("singer")
.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(),
album: s("albumname").unwrap_or_default(),
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("sizeflac"),
size_320mp3: size("size320"),
size_hires: 0,
track: None,
})
}
pub fn parse_response(id: &str, v: &Value) -> Result<Playlist> {
let code = v.get("code").and_then(Value::as_i64).unwrap_or(-1);
let cd = v.pointer("/cdlist/0");
let Some(cd) = cd.filter(|_| code == 0) else {
bail!("歌单 {id} 不存在或不可见(code={code})");
};
let list = cd
.get("songlist")
.or_else(|| cd.get("list"))
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let mut seen = std::collections::HashSet::new();
let songs: Vec<Song> = list
.iter()
.filter_map(song_from_playlist_item)
.filter(|s| seen.insert(s.id.clone()))
.collect();
let name = cd
.get("dissname")
.and_then(Value::as_str)
.map(clean_text)
.filter(|n| !n.is_empty())
.unwrap_or_else(|| format!("playlist-{id}"));
Ok(Playlist { name, songs })
}
pub async fn resolve_id(session: &Session, input: &str) -> Result<String> {
if let Some(id) = parse_id(input) {
return Ok(id);
}
let input = input.trim();
if !input.contains("://") || !is_qq_host(input) {
bail!("无法识别的歌单:{input}(需要歌单 ID 或 QQ 音乐歌单链接)");
}
let resp = crate::net::send(session.client.get(input))
.await
.context("访问歌单链接失败")?;
let final_url = resp.url().to_string();
debug!(final_url, "歌单链接跳转");
parse_id(&final_url).with_context(|| format!("无法从链接中找到歌单 ID:{final_url}"))
}
pub async fn fetch(session: &Session, id: &str) -> Result<Playlist> {
let referer = format!("https://y.qq.com/n/ryqq/playlist/{id}");
let v: Value = crate::net::send(
session
.client
.get(ENDPOINT)
.header("Referer", referer)
.query(&[
("disstid", id),
("type", "1"),
("json", "1"),
("utf8", "1"),
("onlysong", "0"),
("format", "json"),
]),
)
.await
.context("请求歌单失败")?
.error_for_status()?
.json()
.await
.context("歌单响应不是 JSON")?;
parse_response(id, &v)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ids_from_inputs() {
let id = Some("7039749142".to_string());
assert_eq!(parse_id("7039749142"), id);
assert_eq!(parse_id(" 7039749142 "), id);
assert_eq!(parse_id("https://y.qq.com/n/ryqq/playlist/7039749142"), id);
assert_eq!(
parse_id("https://y.qq.com/n/ryqq_v2/playlist/7039749142/"),
id
);
assert_eq!(
parse_id("https://y.qq.com/n/yqq/playlist/7039749142.html#stat=y_new"),
id
);
assert_eq!(
parse_id(
"https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&id=7039749142&hosteuin=x"
),
id
);
assert_eq!(parse_id("https://c.y.qq.com/x?disstid=7039749142"), id);
assert_eq!(
parse_id("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCdEf"),
None
);
assert_eq!(
parse_id("https://music.163.com/playlist?id=7583298906"),
None
);
assert_eq!(parse_id("https://evil.com/y.qq.com/playlist/1"), None);
assert_eq!(parse_id("晴天"), None);
assert_eq!(parse_id(""), None);
}
#[test]
fn hosts() {
assert!(is_qq_host("https://y.qq.com/n/ryqq/playlist/1"));
assert!(is_qq_host("http://C6.Y.QQ.COM:443/u"));
assert!(!is_qq_host("https://notqq.com/y.qq.com"));
assert!(!is_qq_host("https://y.qq.com.evil.com/"));
}
#[test]
fn parse_real_shape() {
let v = json!({"code": 0, "cdlist": [{
"dissname": "百听不厌的<em>周杰伦</em>",
"songlist": [
{"songmid": "0039MnYb0qxYhV", "songname": "晴天", "interval": 269,
"singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}],
"albummid": "000MkMni19ClKG", "albumname": "叶惠美",
"sizeflac": 55397039, "size320": 10792943},
{"songmid": "0039MnYb0qxYhV", "songname": "重复"},
{"songname": "缺 mid"}
]}]});
let p = parse_response("7039749142", &v).unwrap();
assert_eq!(p.name, "百听不厌的周杰伦");
assert_eq!(p.songs.len(), 1);
let s = &p.songs[0];
assert_eq!(
(s.id.as_str(), s.title.as_str(), s.album.as_str()),
("0039MnYb0qxYhV", "晴天", "叶惠美")
);
assert_eq!(s.singers, ["周杰伦"]);
assert_eq!(
(s.interval, s.size_flac, s.size_320mp3),
(269, 55397039, 10792943)
);
assert_eq!(
s.cover_url.as_deref().unwrap(),
"https://y.gtimg.cn/music/photo_new/T002R800x800M000000MkMni19ClKG.jpg"
);
}
#[test]
fn missing_playlist() {
let v = json!({"code": -1, "cdnum": 0, "cdlist": []});
let err = parse_response("1", &v).unwrap_err();
assert!(err.to_string().contains("不存在或不可见"));
let p = parse_response("9", &json!({"code": 0, "cdlist": [{"songlist": []}]})).unwrap();
assert_eq!(p.name, "playlist-9");
assert!(p.songs.is_empty());
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use crate::config::Config;
use std::time::Duration;
#[tokio::test]
#[ignore = "需要网络"]
async fn fetch_real_playlist() {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.build()
.unwrap();
let session = Session::new(client, Config::default());
let p = fetch(&session, "3230056671").await.unwrap();
assert_eq!(p.name, "周杰伦作品正序集");
assert!(p.songs.len() > 100);
assert!(
p.songs
.iter()
.all(|s| !s.id.is_empty() && !s.title.is_empty())
);
assert!(fetch(&session, "1").await.is_err());
}
}