use anyhow::{Context, Result, bail};
use tracing::debug;
use super::{Session, is_qq_host};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SongRef {
Mid(String),
Id(u64),
}
impl std::fmt::Display for SongRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SongRef::Mid(m) => f.write_str(m),
SongRef::Id(id) => write!(f, "{id}"),
}
}
}
fn is_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit())
}
fn is_mid(s: &str) -> bool {
(8..=32).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn ref_from_token(s: &str) -> Option<SongRef> {
if is_id(s) {
s.parse().ok().map(SongRef::Id)
} else if is_mid(s) {
Some(SongRef::Mid(s.to_string()))
} else {
None
}
}
pub fn parse_link(input: &str) -> Option<SongRef> {
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, ""));
let pairs: Vec<(&str, &str)> = query.split('&').filter_map(|p| p.split_once('=')).collect();
for keys in [
&["songmid", "songMid", "song_mid"][..],
&["songid", "songId", "song_id"],
] {
if let Some(r) = pairs
.iter()
.filter(|(k, _)| keys.contains(k))
.find_map(|(_, v)| 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("songDetail") || *s == "song")?;
let token = segs.get(pos + 1)?;
ref_from_token(token.trim_end_matches(".html").trim_end_matches(".htm"))
}
pub fn parse_arg(input: &str) -> Option<SongRef> {
let input = input.trim();
if input.contains("://") {
parse_link(input)
} else {
ref_from_token(input)
}
}
pub async fn resolve_ref(session: &Session, input: &str) -> Result<SongRef> {
if let Some(r) = parse_arg(input) {
return Ok(r);
}
let input = input.trim();
if !input.contains("://") || !is_qq_host(input) {
bail!("无法识别的歌曲:{input}(需要 songmid、songid 或 QQ 音乐歌曲链接)");
}
let resp = crate::net::send(session.client.get(input))
.await
.context("访问歌曲链接失败")?;
let final_url = resp.url().to_string();
debug!(final_url, "歌曲链接跳转");
parse_link(&final_url).with_context(|| format!("无法从链接中找到歌曲:{final_url}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn mid(s: &str) -> Option<SongRef> {
Some(SongRef::Mid(s.to_string()))
}
#[test]
fn args() {
assert_eq!(parse_arg("001zMQr71F1Qo8"), mid("001zMQr71F1Qo8"));
assert_eq!(parse_arg(" 001zMQr71F1Qo8 "), mid("001zMQr71F1Qo8"));
assert_eq!(parse_arg("718477"), Some(SongRef::Id(718477)));
assert_eq!(parse_arg("夜曲"), None);
assert_eq!(parse_arg("abc"), None);
assert_eq!(parse_arg(""), None);
}
#[test]
fn links() {
let m = mid("001zMQr71F1Qo8");
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
m
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq_v2/songDetail/001zMQr71F1Qo8/"),
m
);
assert_eq!(
parse_link("https://y.qq.com/n/yqq/song/001zMQr71F1Qo8.html#stat=x"),
m
);
assert_eq!(
parse_link(
"https://i.y.qq.com/v8/playsong.html?ADTAG=share&songid=718477&songmid=001zMQr71F1Qo8&type=0"
),
m
);
assert_eq!(
parse_link("https://i.y.qq.com/v8/playsong.html?songid=718477"),
Some(SongRef::Id(718477))
);
assert_eq!(
parse_arg("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
m
);
assert_eq!(
parse_link("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCdEf"),
None
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/playlist/7039749142"),
None
);
assert_eq!(
parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
None
);
assert_eq!(
parse_link("https://evil.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
None
);
}
#[test]
fn display() {
assert_eq!(SongRef::Id(1).to_string(), "1");
assert_eq!(SongRef::Mid("x".into()).to_string(), "x");
}
}