//! 批量下载编排:勾选歌曲 -> 逐首 搜索 + 匹配 + 下载 use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use regex::Regex; use serde::Serialize; use serde_json::{json, Value}; use crate::config::Settings; use crate::music::{self, SongView}; use crate::state::{gen_id, now_ts, TaskStore}; fn norm(s: &str) -> String { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| Regex::new(r#"[\s()()\[\]【】\-_.·~~']"#).unwrap()); re.replace_all(&s.to_lowercase(), "").into_owned() } /// Ratcliff-Obershelp 最长公共子串递归匹配数(对齐 difflib.SequenceMatcher) fn match_count(a: &[char], b: &[char]) -> usize { if a.is_empty() || b.is_empty() { return 0; } let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1]; let (mut best, mut bi, mut bj) = (0, 0, 0); for i in 1..=a.len() { for j in 1..=b.len() { if a[i - 1] == b[j - 1] { dp[i][j] = dp[i - 1][j - 1] + 1; if dp[i][j] > best { best = dp[i][j]; bi = i - best; bj = j - best; } } } } if best == 0 { 0 } else { best + match_count(&a[..bi], &b[..bj]) + match_count(&a[bi + best..], &b[bj + best..]) } } fn sequence_ratio(a: &str, b: &str) -> f64 { let a: Vec = a.chars().collect(); let b: Vec = b.chars().collect(); let total = a.len() + b.len(); if total == 0 { return 1.0; } 2.0 * match_count(&a, &b) as f64 / total as f64 } /// 候选歌曲与期望的相似度打分:标题 0.6 + 歌手 0.3 + 无损 0.1 pub fn match_score(title: &str, artist: &str, cand: &SongView) -> f64 { let (t1, t2) = (norm(title), norm(&cand.title)); let (a1, a2) = (norm(artist), norm(&cand.artist)); if t1.is_empty() || t2.is_empty() { return 0.0; } let ts = if t1.contains(&t2) || t2.contains(&t1) { 1.0 } else { sequence_ratio(&t1, &t2) }; let as_ = if !a1.is_empty() && !a2.is_empty() { if a1.contains(&a2) || a2.contains(&a1) { 1.0 } else { sequence_ratio(&a1, &a2) } } else { 0.0 }; let quality = if cand.ext == "flac" { 0.1 } else { 0.0 }; ts * 0.6 + as_ * 0.3 + quality } /// 批量下载批次状态(内存实现) #[derive(Default)] pub struct BatchStore { batches: Mutex>, } #[derive(Debug, Clone, Serialize)] pub struct Batch { pub id: String, pub created_at: f64, pub songs: Vec, } #[derive(Debug, Clone, Serialize)] pub struct BatchSong { pub title: String, pub artist: String, pub status: String, // pending / matching / downloading / done / failed / unmatched pub source: Option, pub ext: Option, pub file: Option, pub error: Option, } impl BatchStore { pub fn new() -> Self { Self::default() } pub fn create(&self, songs: Vec<(String, String)>) -> String { let batch_id = gen_id(12); let batch = Batch { id: batch_id.clone(), created_at: now_ts(), songs: songs .into_iter() .map(|(title, artist)| BatchSong { title, artist, status: "pending".to_string(), source: None, ext: None, file: None, error: None, }) .collect(), }; self.batches .lock() .unwrap() .insert(batch_id.clone(), batch); batch_id } pub fn update_song(&self, batch_id: &str, idx: usize, f: impl FnOnce(&mut BatchSong)) { if let Some(batch) = self.batches.lock().unwrap().get_mut(batch_id) { if let Some(song) = batch.songs.get_mut(idx) { f(song); } } } pub fn get(&self, batch_id: &str) -> Option { let batch = self.batches.lock().unwrap().get(batch_id).cloned()?; let mut summary: HashMap<&str, usize> = HashMap::new(); for s in &batch.songs { *summary.entry(s.status.as_str()).or_insert(0) += 1; } Some(json!({ "id": batch.id, "created_at": batch.created_at, "songs": batch.songs, "summary": summary, })) } } /// 串行 worker:单首歌 搜索 -> 打分匹配 -> 下载(状态同步到批次与任务中心) #[allow(clippy::too_many_arguments)] pub async fn run_batch_song( cfg: Arc, batches: Arc, tasks: Arc, batch_id: String, idx: usize, title: String, artist: String, task_id: String, ) { batches.update_song(&batch_id, idx, |s| s.status = "matching".into()); tasks.update(&task_id, |t| t.status = "matching".into()); let keyword = format!("{artist} {title}").trim().to_string(); let outcome = music::search(cfg.clone(), keyword).await; let mut best_idx: Option = None; let mut best_score = 0.0f64; for (i, (_, view, _)) in outcome.items.iter().enumerate() { let score = match_score(&title, &artist, view); if score > best_score { best_score = score; best_idx = Some(i); } } let Some(best_i) = best_idx.filter(|_| best_score >= cfg.match_threshold) else { let msg = format!("最高匹配度 {best_score:.2}"); batches.update_song(&batch_id, idx, |s| { s.status = "unmatched".into(); s.error = Some(msg.clone()); }); tasks.update(&task_id, |t| { t.status = "unmatched".into(); t.error = Some(msg); }); return; }; // 提前取出 owned 值,避免引用跨越 await let (_, view, raw) = &outcome.items[best_i]; let (source_name, ext, raw) = (view.source.clone(), view.ext.clone(), raw.clone()); batches.update_song(&batch_id, idx, |s| { s.status = "downloading".into(); s.source = Some(source_name.clone()); s.ext = Some(ext.clone()); }); tasks.update(&task_id, |t| { t.status = "downloading".into(); t.source = Some(source_name); t.ext = Some(ext); }); match music::download(cfg, raw).await { Ok(path) => { let file = path.display().to_string(); batches.update_song(&batch_id, idx, |s| { s.status = "done".into(); s.file = Some(file.clone()); }); tasks.update(&task_id, |t| { t.status = "done".into(); t.file = Some(file.clone()); }); tracing::info!("batch matched: {title} -> {file} ({best_score:.2})"); } Err(e) => { batches.update_song(&batch_id, idx, |s| { s.status = "failed".into(); s.error = Some(e.clone()); }); tasks.update(&task_id, |t| { t.status = "failed".into(); t.error = Some(e); }); } } }