//! melodl 封装:多源并行搜索(总超时)+ 单曲下载 use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use melodl::core::{registry, SearchCtx}; use melodl::{MusicClient, SongInfo}; use serde::Serialize; use tokio::sync::mpsc; use crate::config::Settings; use crate::state::gen_id; static CLIENT: OnceLock = OnceLock::new(); static HTTP: OnceLock = OnceLock::new(); /// 前端展示用的歌曲视图(字段名对齐前端契约) #[derive(Debug, Clone, Serialize)] pub struct SongView { pub token: String, pub title: String, pub artist: String, pub album: Option, pub source: String, pub ext: String, pub size: Option, pub duration: Option, pub cover_url: Option, } pub struct SearchOutcome { pub items: Vec<(String, SongView, SongInfo)>, pub sources_ok: Vec, pub sources_failed: Vec, pub elapsed: f64, } /// 懒加载单例,避免启动时初始化所有源 pub fn client(cfg: &Settings) -> &'static MusicClient { CLIENT.get_or_init(|| { let sources = resolve_sources(&cfg.music_sources); MusicClient::builder() .sources(sources) .work_dir(cfg.music_dir.clone()) .search_size_per_source(cfg.search_size) .build() .expect("melodl client build failed") }) } /// MUSIC_SOURCES 兼容 musicdl 命名(MiguMusicClient -> migu),未知源跳过 fn resolve_sources(names: &[String]) -> Vec { let available = registry::available_sources(); let mut out = vec![]; for name in names { let short = name .strip_suffix("MusicClient") .unwrap_or(name) .to_lowercase(); if available.contains(&short.as_str()) { out.push(short); } else { tracing::warn!("未知音源,跳过: {name}"); } } if out.is_empty() { out = vec!["migu".into(), "netease".into(), "qq".into(), "kuwo".into()]; } out } /// 搜索编排自实现(而非 client.search):总超时时保留已完成源的结果 fn http() -> &'static reqwest::Client { HTTP.get_or_init(|| { reqwest::Client::builder() .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36") .no_proxy() .timeout(Duration::from_secs(30)) .build() .expect("http client build failed") }) } pub async fn search(cfg: Arc, keyword: String) -> SearchOutcome { let start = Instant::now(); let names: Vec = client(&cfg) .source_names() .into_iter() .map(str::to_string) .collect(); let (tx, mut rx) = mpsc::unbounded_channel::<(String, melodl::Result>)>(); let mut pending: HashSet = HashSet::new(); for name in names { let Some(source) = registry::build(&name) else { continue }; let ctx = SearchCtx { client: http().clone(), search_size_per_source: cfg.search_size, search_size_per_page: cfg.search_size.min(10), max_retries: 3, events: None, }; let keyword = keyword.clone(); let tx = tx.clone(); pending.insert(name.clone()); tokio::spawn(async move { let result = source.search(&keyword, &ctx).await; let _ = tx.send((name, result)); }); } drop(tx); let deadline = start + Duration::from_secs_f64(cfg.search_timeout); let mut songs: Vec = vec![]; let mut sources_ok: Vec = vec![]; let mut sources_failed: Vec = vec![]; while !pending.is_empty() { let remain = deadline.saturating_duration_since(Instant::now()); if remain.is_zero() { break; } match tokio::time::timeout(remain, rx.recv()).await { Ok(Some((name, Ok(s)))) => { pending.remove(&name); sources_ok.push(name); songs.extend(s); } Ok(Some((name, Err(e)))) => { pending.remove(&name); tracing::warn!("search source failed {name}: {e}"); sources_failed.push(name); } Ok(None) => break, Err(_) => break, // 总超时:pending 里的源放弃 } } if !pending.is_empty() { let timed_out: Vec = pending.into_iter().collect(); tracing::warn!("search source timeout: {}", timed_out.join(", ")); sources_failed.extend(timed_out); } let items = dedup(songs) .into_iter() .map(|song| { let token = gen_id(32); let view = SongView { token: token.clone(), title: song.song_name.clone(), artist: song.singers.clone(), album: song.album.clone(), source: song.source.clone(), ext: song .ext .clone() .unwrap_or_default() .trim_start_matches('.') .to_string(), size: song .file_size_bytes .filter(|b| *b > 0) .map(|_| song.file_size_display()), duration: song.duration_s.map(|_| song.duration_display()), cover_url: song.cover_url.clone(), }; (token, view, song) }) .collect(); SearchOutcome { items, sources_ok, sources_failed, elapsed: (start.elapsed().as_secs_f64() * 10.0).round() / 10.0, } } /// 按 source+root_source+identifier 去重,过滤无下载链接的结果 fn dedup(songs: Vec) -> Vec { let mut seen = HashSet::new(); songs .into_iter() .filter(|s| { s.with_valid_download_url() && seen.insert(( s.source.clone(), s.root_source.clone().unwrap_or_default(), s.identifier.clone(), )) }) .collect() } /// 下载单曲,melodl 自动写入 ID3/封面/歌词,返回文件路径 pub async fn download(cfg: Arc, song: SongInfo) -> Result { let name = song.song_name.clone(); let mut paths = client(&cfg).download(vec![song]).await; let path = paths .drain(..) .next() .ok_or_else(|| format!("下载失败: {name}"))?; tracing::info!("downloaded: {}", path.display()); Ok(path) }