commit b2dc3e75bc4d1d2cb12c1febaec0cb16f51865cc from: mtmn date: Thu May 7 10:36:21 2026 UTC feat: unify naming, deprecate --all argument commit - 782c6c972fe99b4e4e4b83810bc94e2c22bfbb17 commit + b2dc3e75bc4d1d2cb12c1febaec0cb16f51865cc blob - 7aaff387edc36e8f4d4daf19a5ab2691650d281e blob + 80bedbaac5f27a90291a80ad1692e992bedd7588 --- src/main.rs +++ src/main.rs @@ -20,20 +20,17 @@ use walkdir::WalkDir; Fetch metadata for an artist and album: $ hakuna --artist 'Djrum' --album 'Under Tangled Silence' - Preview tags for a single file: + Preview tags for a file: $ hakuna --read file.mp3 - Write tags to a single file: + Write tags to a file: $ hakuna -d TOKEN --write file.mp3 - Edit tags manually for a single file: + Edit tags manually: $ hakuna --edit file.mp3 - Process all files in a directory: - $ hakuna -d TOKEN --write --all /path/to/music/ - - Edit all files in a directory with same values: - $ hakuna --edit --all /path/to/music/" + Process a directory: + $ hakuna -d TOKEN --write /path/to/music/" )] #[allow(clippy::struct_excessive_bools)] struct Args { @@ -61,10 +58,6 @@ struct Args { #[arg(short, long, default_value_t = false)] edit: bool, - /// Process all files in directory(ies) at once - #[arg(short, long, default_value_t = false)] - all: bool, - /// Discogs API token #[arg(short = 'd', long)] discogs_token: Option, @@ -95,7 +88,6 @@ fn collect_audio_files(path: &Path) -> Vec Result<()> { - // Show help if no arguments provided if env::args().len() == 1 { let _ = Args::parse_from(["hakuna", "--help"]); return Ok(()); @@ -118,17 +110,12 @@ async fn main() -> Result<()> { } if args.edit { - if args.all { - tagging::edit_files_batch(&all_files).await?; - } else { - for file_path in all_files { - if let Err(e) = tagging::edit_file(&file_path).await { - eprintln!("Failed to edit file {}: {e:?}", file_path.display()); - } + for file_path in all_files { + if let Err(e) = tagging::edit_file(&file_path).await { + eprintln!("Failed to edit file {}: {e:?}", file_path.display()); } } } else { - // Always use caching for read/write operations tagging::process_files_batch(&ctx, &all_files, args.read, args.write).await?; } } else if let (Some(artist), Some(album)) = (args.artist, args.album) { blob - f6576b3e9f703f289c94a59677408cf4fb0900f6 blob + 26ca26f34bfcbe80759e3bd7f13f33f49d87f04f --- src/tagging.rs +++ src/tagging.rs @@ -30,16 +30,16 @@ pub async fn process_files_batch( let mut files_by_release: HashMap<(String, String), Vec> = HashMap::new(); for path in paths { - let abs_path = path.clone(); + let abs_path = path.canonicalize().unwrap_or_else(|_| path.clone()); if !abs_path.exists() { eprintln!("Skipping non-existent file: {}", path.display()); continue; } - match read_tagged_file(&abs_path) { + match read_file_tags(&abs_path) { Ok((_, artist, album)) => { files_by_release - .entry((artist.clone(), album.clone())) + .entry((artist, album)) .or_default() .push(abs_path); } @@ -62,7 +62,6 @@ pub async fn process_files_batch( for ((artist, album), file_paths) in files_by_release { println!("\nFetching metadata for: {artist} - {album}"); - // Fetch metadata once per release let result = match crate::metadata::fetch::process_query(ctx, &artist, &album).await { Ok(r) => r, Err(e) => { @@ -76,13 +75,10 @@ pub async fn process_files_batch( print_proposed_tags(&result); } - // Apply to all files in this release group for file_path in file_paths { if write { - match write_single_file(&file_path, &result) { - Ok(()) => { - success_count += 1; - } + match write_file_tags(&file_path, &result) { + Ok(()) => success_count += 1, Err(e) => { eprintln!(" Failed to write {}: {e:?}", file_path.display()); error_count += 1; @@ -101,14 +97,12 @@ pub async fn process_files_batch( Ok(()) } -fn write_single_file(path: &Path, metadata: &FetchedMetadata) -> Result<()> { - let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - - let file = File::open(&abs_path) - .with_context(|| format!("Failed to open file: {}", abs_path.display()))?; +fn write_file_tags(path: &Path, metadata: &FetchedMetadata) -> Result<()> { + let file = + File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?; let mut probe = Probe::new(file); - if let Some(file_type) = abs_path + if let Some(file_type) = path .extension() .and_then(|e| e.to_str()) .and_then(lofty::file::FileType::from_ext) @@ -118,48 +112,14 @@ fn write_single_file(path: &Path, metadata: &FetchedMe let mut tagged_file = probe .read() - .with_context(|| format!("Failed to read tags from {}", abs_path.display()))?; + .with_context(|| format!("Failed to read tags from {}", path.display()))?; let tag = tagged_file .primary_tag_mut() .context("No primary tag found")?; - write_tags(tag, metadata); + apply_tags(tag, metadata); - let backup_path = abs_path.with_extension(format!( - "backup.{}", - abs_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("bak") - )); - - std::fs::copy(&abs_path, &backup_path) - .with_context(|| format!("Failed to create backup: {}", backup_path.display()))?; - - match tagged_file.save_to_path(&abs_path, WriteOptions::default()) { - Ok(()) => { - let _ = std::fs::remove_file(&backup_path); - } - Err(e) => { - if std::path::Path::exists(&backup_path) { - if let Err(restore_err) = std::fs::copy(&backup_path, &abs_path) { - eprintln!("ERROR: Failed to restore from backup: {restore_err}"); - eprintln!( - "WARNING: Original file may be corrupted. Backup preserved at: {}", - backup_path.display() - ); - return Err( - anyhow::anyhow!("Failed to restore from backup: {restore_err}").context(e), - ); - } - eprintln!("Restored file from backup after write failure"); - let _ = std::fs::remove_file(&backup_path); - } - return Err(anyhow::anyhow!("Failed to write tags to file").context(e)); - } - } - - Ok(()) + save_with_backup(&mut tagged_file, path) } fn parse_comma_separated(input: &str) -> Vec { @@ -171,7 +131,7 @@ fn parse_comma_separated(input: &str) -> Vec { .collect() } -async fn prompt_edit_field( +async fn prompt_field( stdin: &mut BufReader, stdout: &mut tokio::io::Stdout, field_name: &str, @@ -193,55 +153,17 @@ async fn prompt_edit_field( } } -async fn prompt_edit_field_batch( - stdin: &mut BufReader, - stdout: &mut tokio::io::Stdout, - field_name: &str, -) -> Result> { - print!("{field_name}: "); - stdout.flush().await?; +fn apply_tags(tag: &mut Tag, metadata: &FetchedMetadata) { + tag.remove_key(ItemKey::Genre); + tag.remove_key(ItemKey::Label); - let mut input = String::new(); - stdin.read_line(&mut input).await?; - let input = input.trim(); - - if input.is_empty() { - Ok(None) - } else if input.eq_ignore_ascii_case("clear") { - Ok(Some(String::new())) - } else { - Ok(Some(input.to_string())) + if !metadata.genres.is_empty() { + tag.insert_text(ItemKey::Genre, metadata.genres.join("/")); } -} -fn update_tag(tag: &mut Tag, artist: &str, album: &str, genre: &str, label: &str) { - if artist.is_empty() { - tag.remove_key(ItemKey::TrackArtist); - } else { - tag.insert_text(ItemKey::TrackArtist, artist.to_string()); + if let Some(label) = metadata.labels.first() { + tag.insert_text(ItemKey::Label, label.clone()); } - - if album.is_empty() { - tag.remove_key(ItemKey::AlbumTitle); - } else { - tag.insert_text(ItemKey::AlbumTitle, album.to_string()); - } - - if genre.is_empty() { - tag.remove_key(ItemKey::Genre); - } else { - let genres = parse_comma_separated(genre); - tag.insert_text(ItemKey::Genre, genres.join("/")); - } - - if label.is_empty() { - tag.remove_key(ItemKey::Label); - } else { - let labels = parse_comma_separated(label); - if let Some(first) = labels.first() { - tag.insert_text(ItemKey::Label, first.clone()); - } - } } fn save_with_backup(tagged_file: &mut TaggedFile, path: &Path) -> Result<()> { @@ -281,13 +203,15 @@ fn save_with_backup(tagged_file: &mut TaggedFile, path pub async fn edit_file(path: &Path) -> Result<()> { let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - let path_display = abs_path.display(); if !abs_path.exists() { - return Err(anyhow::anyhow!("File does not exist: {path_display}")); + return Err(anyhow::anyhow!( + "File does not exist: {}", + abs_path.display() + )); } - let (mut tagged_file, current_artist, current_album) = read_tagged_file(&abs_path)?; + let (mut tagged_file, current_artist, current_album) = read_file_tags(&abs_path)?; let tag = tagged_file.primary_tag().context("No primary tag found")?; let current_genre = tag @@ -304,119 +228,20 @@ pub async fn edit_file(path: &Path) -> Result<()> { let mut stdin = BufReader::new(io::stdin()); let mut stdout = io::stdout(); - let artist = prompt_edit_field(&mut stdin, &mut stdout, "Artist", ¤t_artist).await?; - let album = prompt_edit_field(&mut stdin, &mut stdout, "Album", ¤t_album).await?; - let genre = prompt_edit_field(&mut stdin, &mut stdout, "Genre", ¤t_genre).await?; - let label = prompt_edit_field(&mut stdin, &mut stdout, "Label", ¤t_label).await?; + let artist = prompt_field(&mut stdin, &mut stdout, "Artist", ¤t_artist).await?; + let album = prompt_field(&mut stdin, &mut stdout, "Album", ¤t_album).await?; + let genre = prompt_field(&mut stdin, &mut stdout, "Genre", ¤t_genre).await?; + let label = prompt_field(&mut stdin, &mut stdout, "Label", ¤t_label).await?; let tag = tagged_file .primary_tag_mut() .context("No primary tag found")?; update_tag(tag, &artist, &album, &genre, &label); - save_with_backup(&mut tagged_file, &abs_path)?; - - Ok(()) + save_with_backup(&mut tagged_file, &abs_path) } -pub async fn edit_files_batch(paths: &[std::path::PathBuf]) -> Result<()> { - if paths.is_empty() { - println!("No audio files found to edit."); - return Ok(()); - } - - let mut stdin = BufReader::new(io::stdin()); - let mut stdout = io::stdout(); - - let artist = prompt_edit_field_batch(&mut stdin, &mut stdout, "Artist").await?; - let album = prompt_edit_field_batch(&mut stdin, &mut stdout, "Album").await?; - let genre = prompt_edit_field_batch(&mut stdin, &mut stdout, "Genre").await?; - let label = prompt_edit_field_batch(&mut stdin, &mut stdout, "Label").await?; - - let mut success_count = 0; - - for path in paths { - let abs_path = path.clone(); - - if !abs_path.exists() { - eprintln!("Skipping non-existent file: {}", path.display()); - continue; - } - - match edit_apply_single( - &abs_path, - artist.as_deref(), - album.as_deref(), - genre.as_deref(), - label.as_deref(), - ) { - Ok(()) => { - success_count += 1; - } - Err(e) => { - eprintln!("Failed to edit {}: {e:?}", path.display()); - } - } - } - - println!("Edited {success_count} of {} files", paths.len()); - - Ok(()) -} - -fn edit_apply_single( - path: &Path, - artist: Option<&str>, - album: Option<&str>, - genre: Option<&str>, - label: Option<&str>, -) -> Result<()> { - let (mut tagged_file, _, _) = read_tagged_file(path)?; - let tag = tagged_file - .primary_tag_mut() - .context("No primary tag found")?; - - if let Some(val) = artist { - if val.is_empty() { - tag.remove_key(ItemKey::TrackArtist); - } else { - tag.insert_text(ItemKey::TrackArtist, val.to_string()); - } - } - - if let Some(val) = album { - if val.is_empty() { - tag.remove_key(ItemKey::AlbumTitle); - } else { - tag.insert_text(ItemKey::AlbumTitle, val.to_string()); - } - } - - if let Some(val) = genre { - if val.is_empty() { - tag.remove_key(ItemKey::Genre); - } else { - let genres = parse_comma_separated(val); - tag.insert_text(ItemKey::Genre, genres.join("/")); - } - } - - if let Some(val) = label { - if val.is_empty() { - tag.remove_key(ItemKey::Label); - } else { - let labels = parse_comma_separated(val); - if let Some(first) = labels.first() { - tag.insert_text(ItemKey::Label, first.clone()); - } - } - } - - save_with_backup(&mut tagged_file, path)?; - Ok(()) -} - -fn read_tagged_file(path: &Path) -> Result<(TaggedFile, String, String)> { +fn read_file_tags(path: &Path) -> Result<(TaggedFile, String, String)> { let file = File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?; let mut probe = Probe::new(file); @@ -442,6 +267,36 @@ fn read_tagged_file(path: &Path) -> Result<(TaggedFile Ok((tagged_file, artist, album)) } +fn update_tag(tag: &mut Tag, artist: &str, album: &str, genre: &str, label: &str) { + if artist.is_empty() { + tag.remove_key(ItemKey::TrackArtist); + } else { + tag.insert_text(ItemKey::TrackArtist, artist.to_string()); + } + + if album.is_empty() { + tag.remove_key(ItemKey::AlbumTitle); + } else { + tag.insert_text(ItemKey::AlbumTitle, album.to_string()); + } + + if genre.is_empty() { + tag.remove_key(ItemKey::Genre); + } else { + let genres = parse_comma_separated(genre); + tag.insert_text(ItemKey::Genre, genres.join("/")); + } + + if label.is_empty() { + tag.remove_key(ItemKey::Label); + } else { + let labels = parse_comma_separated(label); + if let Some(first) = labels.first() { + tag.insert_text(ItemKey::Label, first.clone()); + } + } +} + fn print_proposed_tags(metadata: &FetchedMetadata) { println!("Proposed tags:"); println!(" Genre: {}", metadata.genres.join("/")); @@ -451,19 +306,6 @@ fn print_proposed_tags(metadata: &FetchedMetadata) { } } -fn write_tags(tag: &mut Tag, metadata: &FetchedMetadata) { - tag.remove_key(ItemKey::Genre); - tag.remove_key(ItemKey::Label); - - if !metadata.genres.is_empty() { - tag.insert_text(ItemKey::Genre, metadata.genres.join("/")); - } - - if let Some(label) = metadata.labels.first() { - tag.insert_text(ItemKey::Label, label.clone()); - } -} - pub fn print_metadata(result: &FetchedMetadata) { if result.genres.is_empty() { println!("Genres: (none)");