audio tagging utilities
at main 371 lines 12 kB view raw
1use std::{fs, path::{Path, PathBuf}}; 2 3use audiotags::{AudioTag, Tag}; 4use clap::Parser; 5 6/// Simple audio tag reader 7#[derive(Parser, Debug)] 8#[command(author, version, about)] 9struct Args { 10 /// Read files recursively 11 #[arg(short, long)] 12 recursive: bool, 13 14 /// Output format: one of json, print, debug 15 #[arg(short = 'o', long, default_value_t = Output::Json)] 16 output: Output, 17 18 /// Pretty-print output. With `--output json` this prints pretty JSON. With `--output debug` this prints human-friendly text. 19 #[arg(long)] 20 pretty: bool, 21 22 /// Only output files that fail one or more validations (imperfect data) 23 #[arg(long)] 24 only_imperfect: bool, 25 /// Directory to scan 26 directory: String, 27} 28 29#[derive(clap::ValueEnum, Clone, Debug)] 30enum Output { 31 Json, 32 Debug, 33} 34 35impl std::fmt::Display for Output { 36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 match self { 38 Output::Json => write!(f, "json"), 39 Output::Debug => write!(f, "debug"), 40 } 41 } 42} 43 44#[derive(serde::Serialize)] 45struct AudioFileSummary { 46 path: PathBuf, 47 title: String, 48 artist: String, 49 album: Option<String>, 50 #[serde(skip_serializing_if = "Option::is_none")] 51 cover_bytes: Option<u64>, 52 #[serde(skip_serializing_if = "Vec::is_empty")] 53 validation_errors: Vec<String>, 54} 55 56impl AudioFileEntry { 57 fn to_summary_with_validators(&self, validators: Option<&[Box<dyn Validator>]>) -> AudioFileSummary { 58 let title = self.tags.title().unwrap_or("Unknown").to_string(); 59 let artist = self.tags.artist().unwrap_or("Unknown").to_string(); 60 61 let (album, cover_size) = match self.tags.album() { 62 Some(a) => { 63 let size = a.cover.as_ref().map(|c| c.data.len() as u64); 64 (Some(a.title.to_string()), size) 65 } 66 None => (None, None), 67 }; 68 69 // collect validation errors if validators provided 70 let mut errors = Vec::new(); 71 if let Some(vals) = validators { 72 for v in vals.iter() { 73 if let Some(reason) = v.validate(self) { 74 errors.push(reason); 75 } 76 } 77 } 78 79 AudioFileSummary { 80 path: self.path.clone(), 81 title, 82 artist, 83 album, 84 cover_bytes: cover_size, 85 validation_errors: errors, 86 } 87 } 88} 89 90pub struct AudioFileEntry { 91 pub path: PathBuf, 92 pub tags: Box<dyn AudioTag>, 93} 94 95impl std::fmt::Debug for AudioFileEntry { 96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 97 let cover_size = match self.tags.album() { 98 Some(album) => album.cover.as_ref().map(|c| c.data.len() as u64), 99 None => None, 100 }; 101 102 let album_info = self.tags.album().as_ref().map(|a| format!("{} by {}", a.title, a.artist.as_deref().unwrap_or("Unknown Artist"))); 103 104 f.debug_struct("AudioFileEntry") 105 .field("path", &self.path) 106 .field("title", &self.tags.title().unwrap_or("Unknown")) 107 .field("artist", &self.tags.artist().unwrap_or("Unknown")) 108 .field("album", &album_info) 109 .field("cover_size", &cover_size) 110 .finish() 111 } 112} 113 114impl std::fmt::Display for AudioFileEntry { 115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 116 let title = self.tags.title().unwrap_or("Unknown"); 117 let artist = self.tags.artist().unwrap_or("Unknown"); 118 let (album_info, cover_size) = match self.tags.album() { 119 Some(album) => { 120 let artist = album.artist.as_ref().map(|s| &**s).unwrap_or("Unknown Artist"); 121 let info = format!("{} by {}", album.title, artist); 122 let size = album.cover.as_ref().map(|c| c.data.len() as u64); 123 (info, size) 124 } 125 None => ("No album".to_string(), None), 126 }; 127 128 let cover_display = match cover_size { 129 Some(n) => n.to_string(), 130 None => "none".to_string(), 131 }; 132 133 write!(f, "{} | {} | {} | {} | cover_size: {}", self.path.display(), title, artist, album_info, cover_display) 134 } 135} 136 137/// A validation result: None == ok, Some(reason) == failed with reason 138type ValidationResult = Option<String>; 139 140trait Validator { 141 fn validate(&self, entry: &AudioFileEntry) -> ValidationResult; 142} 143 144struct TitleValidator; 145impl Validator for TitleValidator { 146 fn validate(&self, entry: &AudioFileEntry) -> ValidationResult { 147 let title = entry.tags.title(); 148 if title.is_none() || title == Some("") { 149 Some("missing title".to_string()) 150 } else { 151 None 152 } 153 } 154} 155 156struct ArtistValidator; 157impl Validator for ArtistValidator { 158 fn validate(&self, entry: &AudioFileEntry) -> ValidationResult { 159 let artist = entry.tags.artist(); 160 if artist.is_none() || artist == Some("") { 161 Some("missing artist".to_string()) 162 } else { 163 None 164 } 165 } 166} 167 168struct AlbumValidator; 169impl Validator for AlbumValidator { 170 fn validate(&self, entry: &AudioFileEntry) -> ValidationResult { 171 match entry.tags.album() { 172 Some(a) => { 173 if a.title.trim().is_empty() { 174 Some("missing album title".to_string()) 175 } else { 176 None 177 } 178 } 179 None => Some("missing album".to_string()), 180 } 181 } 182} 183 184struct CoverValidator; 185impl Validator for CoverValidator { 186 fn validate(&self, entry: &AudioFileEntry) -> ValidationResult { 187 match entry.tags.album() { 188 Some(a) => match a.cover.as_ref() { 189 Some(c) => { 190 if c.data.is_empty() { 191 Some("empty cover".to_string()) 192 } else { 193 None 194 } 195 } 196 None => Some("missing cover".to_string()), 197 }, 198 None => Some("missing cover".to_string()), 199 } 200 } 201} 202 203fn default_validators() -> Vec<Box<dyn Validator>> { 204 vec![ 205 Box::new(TitleValidator), 206 Box::new(ArtistValidator), 207 Box::new(AlbumValidator), 208 Box::new(CoverValidator), 209 ] 210} 211 212fn is_audio_file(path: &Path) -> bool { 213 if let Some(extension) = path.extension().and_then(|s| s.to_str()) { 214 matches!( 215 extension.to_lowercase().as_str(), 216 "mp3" | "flac" | "ogg" | "wav" | "m4a" | "aac" 217 ) 218 } else { 219 false 220 } 221} 222 223fn read_folder<P: AsRef<Path>>(path: P, recursive: bool) -> Result<Vec<AudioFileEntry>, Box<dyn std::error::Error>> { 224 let dir_path = path.as_ref(); 225 if !dir_path.is_dir() { 226 return Err(format!("Path is not a directory: {}", dir_path.display()).into()); 227 } 228 229 let mut audio_files = Vec::new(); 230 231 // Use a stack for iterative traversal so we can do recursive or single-level scanning 232 let mut dirs = vec![dir_path.to_path_buf()]; 233 234 while let Some(current_dir) = dirs.pop() { 235 let entries = match fs::read_dir(&current_dir) { 236 Ok(e) => e, 237 Err(err) => { 238 eprintln!("Failed to read directory {}: {}", current_dir.display(), err); 239 continue; 240 } 241 }; 242 243 for entry_res in entries { 244 let entry = match entry_res { 245 Ok(e) => e, 246 Err(e) => { 247 eprintln!("Failed to read dir entry in {}: {}", current_dir.display(), e); 248 continue; 249 } 250 }; 251 252 let file_path = entry.path(); 253 254 if file_path.is_dir() { 255 if recursive { 256 dirs.push(file_path); 257 } 258 continue; 259 } 260 261 if !(file_path.is_file() && is_audio_file(&file_path)) { 262 continue; 263 } 264 265 match Tag::new().read_from_path(&file_path) { 266 Ok(tags) => { 267 // store the path relative to the provided root directory 268 let rel_path = if let Ok(rel) = file_path.strip_prefix(dir_path) { 269 rel.to_path_buf() 270 } else if let Some(name) = file_path.file_name() { 271 PathBuf::from(name) 272 } else { 273 // fallback to the full path if all else fails 274 file_path.clone() 275 }; 276 277 audio_files.push(AudioFileEntry { 278 path: rel_path, 279 tags, 280 }); 281 } 282 Err(e) => { 283 eprintln!("Failed to read tags from {}: {:?}", file_path.display(), e); 284 // Continue processing other files instead of failing entirely 285 } 286 } 287 } 288 } 289 290 Ok(audio_files) 291} 292 293 294fn main() { 295 let args = Args::parse(); 296 297 match read_folder(&args.directory, args.recursive) { 298 Ok(audio_files) => { 299 println!("Found {} audio files in '{}':", audio_files.len(), args.directory); 300 301 // total files before filtering 302 let total_files = audio_files.len(); 303 304 // build validators (future: could be dynamic from CLI) 305 let validators = default_validators(); 306 307 // if user requested only imperfect, filter entries that fail any validator 308 let audio_files: Vec<AudioFileEntry> = if args.only_imperfect { 309 audio_files 310 .into_iter() 311 .filter(|e| validators.iter().any(|v| v.validate(e).is_some())) 312 .collect() 313 } else { 314 audio_files 315 }; 316 317 // how many imperfect files we have after filtering 318 let imperfect_count = audio_files.len(); 319 // Decide behavior based on `--pretty` and `--output` 320 if args.pretty { 321 match args.output { 322 Output::Json => { 323 // pretty JSON array 324 let summaries: Vec<_> = audio_files.into_iter().map(|e| e.to_summary_with_validators(Some(&validators))).collect(); 325 match serde_json::to_string_pretty(&summaries) { 326 Ok(s) => println!("{}", s), 327 Err(e) => eprintln!("Failed to serialize summaries to pretty JSON: {}", e), 328 } 329 } 330 Output::Debug => { 331 // pretty + debug => human-friendly printed lines 332 for entry in audio_files { 333 println!("{}", entry); 334 } 335 } 336 } 337 } else { 338 // not pretty: per-entry outputs 339 for entry in audio_files { 340 match args.output { 341 Output::Debug => println!("{:?}", entry), 342 Output::Json => { 343 let summary = entry.to_summary_with_validators(Some(&validators)); 344 match serde_json::to_string(&summary) { 345 Ok(s) => println!("{}", s), 346 Err(e) => eprintln!("Failed to serialize JSON for {}: {}", entry.path.display(), e), 347 } 348 } 349 } 350 } 351 } 352 353 // If requested, print an imperfect summary in yellow at the end 354 if args.only_imperfect { 355 let percent = if total_files > 0 { 356 (imperfect_count as f64 / total_files as f64) * 100.0 357 } else { 358 0.0 359 }; 360 // ANSI yellow 361 let yellow = "\x1b[33m"; 362 let reset = "\x1b[0m"; 363 eprintln!("{}Imperfect files: {}/{} ({:.2}%){}", yellow, imperfect_count, total_files, percent, reset); 364 } 365 } 366 Err(e) => { 367 eprintln!("Error reading folder '{}': {}", args.directory, e); 368 std::process::exit(1); 369 } 370 } 371}