main.rs (10970B)
1 pub mod audio_processing; 2 pub mod constants; 3 pub mod db; 4 pub mod file_io; 5 pub mod import; 6 pub mod types; 7 pub mod utils; 8 9 use anyhow::{Context, Result}; 10 use clap::{Arg, Command}; 11 use rayon::prelude::*; 12 use regex::Regex; 13 use std::path::PathBuf; 14 use walkdir::WalkDir; 15 16 use crate::db::LibraryDb; 17 use crate::db::TrackReport; 18 use crate::file_io::process_file; 19 use crate::import::import_mixxx; 20 use crate::types::TrackError; 21 use crate::utils::{format_primary_key, is_supported_audio_file, print_entries}; 22 23 fn build_cli() -> Command { 24 Command::new("virittaa") 25 .version(clap::crate_version!()) 26 .about("Analyze audio files to detect BPM and Key") 27 .arg( 28 Arg::new("path") 29 .num_args(1..) 30 .help("Files or directories to analyze"), 31 ) 32 .arg( 33 Arg::new("write-tags") 34 .short('t') 35 .long("write-tags") 36 .action(clap::ArgAction::SetTrue) 37 .help("Write detected BPM and Key to file metadata"), 38 ) 39 .arg( 40 Arg::new("no-store") 41 .long("no-store") 42 .action(clap::ArgAction::SetTrue) 43 .help("Skip saving track reports to the database"), 44 ) 45 .arg( 46 Arg::new("force") 47 .short('f') 48 .long("force") 49 .action(clap::ArgAction::SetTrue) 50 .help("Re-analyze files that already have BPM/Key tags"), 51 ) 52 .arg( 53 Arg::new("jobs") 54 .short('j') 55 .long("jobs") 56 .help("Number of threads (0 = all cores)") 57 .default_value("0") 58 .value_parser(clap::value_parser!(usize)), 59 ) 60 .arg( 61 Arg::new("list") 62 .short('l') 63 .long("list") 64 .action(clap::ArgAction::SetTrue) 65 .help("List track reports in the database"), 66 ) 67 .arg( 68 Arg::new("limit") 69 .long("limit") 70 .help("Max entries to list (default: 1000, 0 = all)") 71 .default_value("1000") 72 .value_parser(clap::value_parser!(usize)), 73 ) 74 .arg( 75 Arg::new("query") 76 .long("query") 77 .help("Look up a track by exact key"), 78 ) 79 .arg( 80 Arg::new("search") 81 .short('S') 82 .long("search") 83 .help("Search track reports by regex"), 84 ) 85 .arg( 86 Arg::new("artist") 87 .short('a') 88 .long("artist") 89 .help("Find tracks by artist name prefix"), 90 ) 91 .arg( 92 Arg::new("key") 93 .long("key") 94 .help("Find tracks by key prefix (e.g. 'C Major', 'Am')"), 95 ) 96 .arg( 97 Arg::new("bpm") 98 .long("bpm") 99 .num_args(2) 100 .help("Find tracks with BPM in range MIN MAX"), 101 ) 102 .arg( 103 Arg::new("import-mixxx") 104 .long("import-mixxx") 105 .help("Import tracks from a Mixxx SQLite database"), 106 ) 107 .arg( 108 Arg::new("db-path") 109 .long("db-path") 110 .help("Database directory (default: ~/.local/share/virittaa)"), 111 ) 112 } 113 114 #[allow(clippy::too_many_lines)] 115 fn main() -> Result<()> { 116 // Sled's B-tree operations can overflow the default stack on large databases. 117 std::thread::Builder::new() 118 .stack_size(32 * 1024 * 1024) 119 .spawn(real_main) 120 .expect("Failed to spawn main thread") 121 .join() 122 .expect("Main thread panicked") 123 } 124 125 #[allow(clippy::too_many_lines)] 126 fn real_main() -> Result<()> { 127 let mut cmd = build_cli(); 128 129 if std::env::args().len() == 1 { 130 let _ = cmd.print_help(); 131 return Ok(()); 132 } 133 134 let matches = cmd.get_matches(); 135 136 let write_tags = matches.get_flag("write-tags"); 137 let no_store = matches.get_flag("no-store"); 138 let force = matches.get_flag("force"); 139 let jobs = *matches.get_one::<usize>("jobs").unwrap(); 140 let list_db = matches.get_flag("list"); 141 let query_key = matches.get_one::<String>("query").map(String::as_str); 142 let search_pattern = matches.get_one::<String>("search"); 143 let artist_prefix = matches.get_one::<String>("artist").map(String::as_str); 144 let key_prefix = matches.get_one::<String>("key").map(String::as_str); 145 let import_mixxx_path = matches.get_one::<String>("import-mixxx"); 146 let limit = *matches.get_one::<usize>("limit").unwrap(); 147 let bpm_range: Option<(f64, f64)> = match matches.get_many::<String>("bpm") { 148 Some(vals) => { 149 let v: Vec<&String> = vals.collect(); 150 let min = v[0] 151 .parse::<f64>() 152 .with_context(|| format!("Invalid BPM min value: {}", v[0]))?; 153 let max = v[1] 154 .parse::<f64>() 155 .with_context(|| format!("Invalid BPM max value: {}", v[1]))?; 156 Some((min, max)) 157 } 158 None => None, 159 }; 160 161 let db_path = matches.get_one::<String>("db-path").map_or_else( 162 || { 163 dirs::data_dir().map_or_else( 164 || "~/.local/share/virittaa".to_string(), 165 |p| p.join("virittaa").to_string_lossy().to_string(), 166 ) 167 }, 168 String::from, 169 ); 170 171 let is_db_query = list_db 172 || query_key.is_some() 173 || search_pattern.is_some() 174 || artist_prefix.is_some() 175 || key_prefix.is_some() 176 || bpm_range.is_some() 177 || import_mixxx_path.is_some(); 178 179 if is_db_query { 180 let db = LibraryDb::open(std::path::Path::new(&db_path))?; 181 182 if let Some(mixxx_path) = import_mixxx_path { 183 let count = import_mixxx(&db, mixxx_path)?; 184 println!("Imported {count} tracks from Mixxx database."); 185 return Ok(()); 186 } 187 188 if let Some(query_key) = query_key { 189 match db.get(query_key)? { 190 Some(report) => println!("{query_key}: {report}"), 191 None => eprintln!("No track found for key: {query_key}"), 192 } 193 } else if let Some(prefix) = artist_prefix { 194 let entries = db.find_by_artist(prefix, limit)?; 195 print_entries( 196 &entries, 197 &format!("No tracks found for artist prefix: {prefix}"), 198 "track(s) found.", 199 ); 200 } else if let Some(prefix) = key_prefix { 201 let entries = db.find_by_key(prefix, limit)?; 202 print_entries( 203 &entries, 204 &format!("No tracks found for key prefix: {prefix}"), 205 "track(s) found.", 206 ); 207 } else if let Some((min, max)) = bpm_range { 208 let entries = db.find_by_bpm_range(min, max, limit)?; 209 print_entries( 210 &entries, 211 &format!("No tracks found with BPM between {min} and {max}"), 212 "track(s) found.", 213 ); 214 } else if let Some(pattern) = search_pattern { 215 let re = Regex::new(pattern)?; 216 let entries = db.search(&re, limit)?; 217 print_entries( 218 &entries, 219 &format!("No matches for: {pattern}"), 220 "match(es) found.", 221 ); 222 } else { 223 let entries = db.list(limit)?; 224 let total = db.count()?; 225 if entries.is_empty() { 226 println!("No track reports found in database."); 227 } else { 228 for (key, report) in &entries { 229 println!("{key}: {report}"); 230 } 231 if total > entries.len() { 232 println!("\n{} of {} track(s) shown.", entries.len(), total); 233 } else { 234 println!("\n{} track(s) in database.", entries.len()); 235 } 236 } 237 } 238 return Ok(()); 239 } 240 241 let paths: Vec<String> = matches 242 .get_many::<String>("path") 243 .ok_or_else(|| { 244 anyhow::anyhow!( 245 "Provide file paths, or use --list / --query / --search / --artist / --key / --bpm / --import-mixxx to read the database" 246 ) 247 })? 248 .cloned() 249 .collect(); 250 251 if jobs > 0 { 252 rayon::ThreadPoolBuilder::new() 253 .num_threads(jobs) 254 .build_global() 255 .expect("Failed to build thread pool"); 256 } 257 258 let report_db = if no_store { 259 None 260 } else { 261 Some(LibraryDb::open(std::path::Path::new(&db_path))?) 262 }; 263 264 let files_to_process: Vec<PathBuf> = paths 265 .into_iter() 266 .flat_map(|path_str| { 267 let path = PathBuf::from(&path_str); 268 if path.is_dir() { 269 WalkDir::new(path) 270 .into_iter() 271 .filter_map(Result::ok) 272 .filter(|entry| is_supported_audio_file(entry.path())) 273 .map(|entry| entry.path().to_path_buf()) 274 .collect() 275 } else if path.is_file() { 276 vec![path] 277 } else { 278 vec![] 279 } 280 }) 281 .collect::<Vec<_>>(); 282 283 let (results, errors): (Vec<_>, Vec<_>) = files_to_process 284 .par_iter() 285 .map(|path| { 286 process_file(path, write_tags, force).map_err(|reason| TrackError { 287 path: path.clone(), 288 reason, 289 }) 290 }) 291 .partition(Result::is_ok); 292 293 let results: Vec<_> = results.into_iter().map(|r| r.unwrap()).collect(); 294 let errors: Vec<_> = errors.into_iter().map(|r| r.unwrap_err()).collect(); 295 296 if !errors.is_empty() { 297 for err in &errors { 298 eprintln!("Error: {} - {}", err.path.display(), err.reason); 299 } 300 } 301 302 if let Some(db) = &report_db { 303 let entries: Vec<(String, TrackReport)> = results 304 .iter() 305 .map(|track| { 306 let key = format_primary_key(track.artist.as_deref(), track.track.as_deref()); 307 let report = TrackReport { 308 artist: track.artist.clone(), 309 track: track.track.clone(), 310 bpm: Some(f64::from(track.bpm)), 311 key: Some(track.key.clone()), 312 timestamp: Some( 313 std::time::SystemTime::now() 314 .duration_since(std::time::UNIX_EPOCH) 315 .unwrap_or_default() 316 .as_secs() 317 .cast_signed(), 318 ), 319 }; 320 (key, report) 321 }) 322 .collect(); 323 324 db.save_batch(&entries)?; 325 db.flush()?; 326 327 for report in entries.iter().map(|(_, r)| r) { 328 println!(" Saved: {report}"); 329 } 330 } 331 332 Ok(()) 333 }