audio tagging utilities
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(¤t_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}