commit - 782c6c972fe99b4e4e4b83810bc94e2c22bfbb17
commit + b2dc3e75bc4d1d2cb12c1febaec0cb16f51865cc
blob - 7aaff387edc36e8f4d4daf19a5ab2691650d281e
blob + 80bedbaac5f27a90291a80ad1692e992bedd7588
--- src/main.rs
+++ src/main.rs
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 {
#[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<String>,
#[tokio::main]
async fn main() -> Result<()> {
- // Show help if no arguments provided
if env::args().len() == 1 {
let _ = Args::parse_from(["hakuna", "--help"]);
return Ok(());
}
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
let mut files_by_release: HashMap<(String, String), Vec<std::path::PathBuf>> = 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);
}
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) => {
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;
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)
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<String> {
.collect()
}
-async fn prompt_edit_field(
+async fn prompt_field(
stdin: &mut BufReader<tokio::io::Stdin>,
stdout: &mut tokio::io::Stdout,
field_name: &str,
}
}
-async fn prompt_edit_field_batch(
- stdin: &mut BufReader<tokio::io::Stdin>,
- stdout: &mut tokio::io::Stdout,
- field_name: &str,
-) -> Result<Option<String>> {
- 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<()> {
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
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);
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("/"));
}
}
-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)");