commit 82f71cb083b970d6f5729b00ec6f1ad410a867a9 from: mtmn date: Sat May 9 12:00:40 2026 UTC feat: move audio files commit - cd83e1901ec0458a665c86ebb869f2bc1f6320a7 commit + 82f71cb083b970d6f5729b00ec6f1ad410a867a9 blob - 4591c2737cb0e0d838896ae923b069ca82a2f0b5 blob + a3ffd295f77470bd6dc404e7acb902a4c1a19adf --- Cargo.lock +++ Cargo.lock @@ -9,6 +9,15 @@ source = "registry+https://github.com/rust-lang/crates checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -463,6 +472,7 @@ dependencies = [ "anyhow", "clap", "lofty", + "regex", "reqwest", "serde", "serde_json", @@ -1204,6 +1214,35 @@ dependencies = [ ] [[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] name = "reqwest" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" blob - acd894c01a4fb850468d3f6664f824c98ded09d5 blob + 8cfc71565ba22fc31127eaa01bd4564e818d1ba5 --- Cargo.toml +++ Cargo.toml @@ -13,4 +13,5 @@ serde_json = "1.0" tempfile = "3.8" tokio = { version = "1.0", features = ["full", "macros", "rt-multi-thread"] } url = "2.5" +regex = "1" walkdir = "2" blob - 2a202481ace1d69ac2bc70c769413065155effae blob + bcdbb004e905036e201f005ad461959c484c3bba --- README.md +++ README.md @@ -9,6 +9,7 @@ A command-line tool that fetches music metadata (genre * Process directories recursively * Manually edit artist, album, genre, and label tags * Batch edit multiple files with the same values +* Move and rename files into a structured `label/artist/album/track title` hierarchy ## Building @@ -35,7 +36,9 @@ cargo build --release * `--read`: Read tags from file and show proposed metadata. * `--write`: Write fetched metadata to audio file tags. * `-e`, `--edit`: Manually edit artist, album, genre, and label tags. -* `-a`, `--all`: Process all files in directory(ies) at once (use with `--edit` or `--write`). +* `-m`, `--move`: Move and rename files into a `label/artist/album/track title` hierarchy based on their tags. Falls back to `artist/album/track title` when no label tag is set. +* `-o`, `--output-dir `: Output directory for moved files (defaults to each file's own directory). +* `-n`, `--dry-run`: Show what would be moved without moving any files. * `-d`, `--discogs-token `: Discogs API token for better results. * `-h`, `--help`: Print help information. * `-V`, `--version`: Print version information. @@ -60,6 +63,15 @@ cargo build --release # Batch edit all files in a directory with the same values ./target/release/hakuna --edit --all /path/to/music/ + +# Move and rename files based on their current tags +./target/release/hakuna --move /path/to/music/ + +# Write tags and move in one pass, moving files to a new location +./target/release/hakuna -d DISCOGS_TOKEN --write --move --output-dir /music/library/ /path/to/incoming/ + +# Preview what would be moved without moving anything +./target/release/hakuna --move --dry-run /path/to/music/ ``` ## API Credentials blob - fe70eb45e9a9308bc0afb9bbaf059bbaa07b1df7 blob + bfa784ffd1106f2219dcad0d6c77aa42ae3fb8b9 --- src/main.rs +++ src/main.rs @@ -1,6 +1,7 @@ mod context; mod fetchers; mod metadata; +mod rename; mod tagging; use anyhow::Result; @@ -58,6 +59,18 @@ struct Args { #[arg(short, long, default_value_t = false)] edit: bool, + /// Move and rename files into label/artist/album/track hierarchy based on tags + #[arg(short = 'm', long = "move", default_value_t = false)] + r#move: bool, + + /// Output directory for moved files (defaults to each file's own directory) + #[arg(short = 'o', long)] + output_dir: Option, + + /// Don't actually move files, only show what would happen + #[arg(short = 'n', long, default_value_t = false)] + dry_run: bool, + /// Discogs API token #[arg(short = 'd', long)] discogs_token: Option, @@ -112,14 +125,30 @@ async fn main() -> Result<()> { if args.edit { tagging::edit_files(&all_files).await?; } else if args.show_tags { - for file_path in all_files { - if let Err(e) = tagging::show_file_tags(&file_path) { + for file_path in &all_files { + if let Err(e) = tagging::show_file_tags(file_path) { eprintln!("Failed to read tags from {}: {e:?}", file_path.display()); } } } else { tagging::process_files_batch(&ctx, &all_files, args.write, args.write).await?; } + + if args.r#move { + for file_path in &all_files { + let out = args + .output_dir + .as_deref() + .unwrap_or_else(|| file_path.parent().unwrap_or(Path::new("."))); + match rename::rename_file(file_path, out, args.dry_run) { + Ok(Some(new_path)) => { + println!("{}: moved to {}", file_path.display(), new_path.display()); + } + Ok(None) => (), + Err(e) => eprintln!("cannot move {}: {e:?}", file_path.display()), + } + } + } } else if let (Some(artist), Some(album)) = (args.artist, args.album) { let result = metadata::fetch::process_query(&ctx, &artist, &album).await?; print_metadata(&result); blob - /dev/null blob + a9a30fa238ed7d87180f0135a5a9a1646bc37eb1 (mode 644) --- /dev/null +++ src/rename.rs @@ -0,0 +1,131 @@ +use anyhow::{Context, Result}; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::{Accessor, ItemKey, Tag}; +use regex::Regex; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +const ADDITIONAL_ACCEPTED_CHARS: &[char] = &['.', '-', '(', ')', ',']; + +fn clean_part(part: &str) -> String { + part.chars() + .map(|c| { + if c.is_alphanumeric() || c.is_whitespace() || ADDITIONAL_ACCEPTED_CHARS.contains(&c) { + c + } else { + '_' + } + }) + .collect() +} + +fn rename_creating_dirs(from: &Path, to: &Path) -> Result<()> { + fs::create_dir_all(to.parent().context("Refusing to move to FS root")?)?; + + if let Err(err) = fs::rename(from, to) { + if err.kind() == std::io::ErrorKind::CrossesDevices { + fs::copy(from, to)?; + fs::remove_file(from)?; + } else { + return Err(err.into()); + } + } + Ok(()) +} + +fn safe_truncate(s: &mut String, max_chars: usize) { + if let Some((idx, _)) = s.char_indices().nth(max_chars) { + s.truncate(idx); + } +} + +static MULTI_DOT_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"\.\.+").expect("BUG: Invalid regex")); +const MAX_PATH_PART_LEN: usize = 64; + +fn normalise_dirs(path_part: String) -> PathBuf { + PathBuf::from(path_part) + .components() + .map(|c| { + let mut s = c + .as_os_str() + .to_os_string() + .into_string() + .expect("invalid path"); + safe_truncate(&mut s, MAX_PATH_PART_LEN); + s = s.replace('/', "_"); + s = s.trim().to_string(); + s = MULTI_DOT_RE.replace_all(&s, ".").to_string(); + let s = s.trim_start_matches('.').to_string(); + if s.is_empty() { "_".to_string() } else { s } + }) + .collect() +} + +fn add_extension(path: PathBuf, ext: impl AsRef) -> PathBuf { + let mut os_string: OsString = path.into(); + os_string.push("."); + os_string.push(ext.as_ref()); + os_string.into() +} + +fn build_path_from_tag(tag: &Tag) -> String { + let artist = clean_part(tag.artist().as_deref().unwrap_or("Unknown Artist")); + let album = clean_part(tag.album().as_deref().unwrap_or("Unknown Album")); + let title = clean_part(tag.title().as_deref().unwrap_or("Unknown Title")); + let track = format!("{:02}", tag.track().unwrap_or_default()); + let label = tag.get_string(ItemKey::Label).map(clean_part); + + match label { + Some(l) => format!("{l}/{artist}/{album}/{track} {title}"), + None => format!("{artist}/{album}/{track} {title}"), + } +} + +pub fn rename_track( + path: &Path, + tag: &Tag, + output_path: &Path, + dry_run: bool, +) -> Result> { + let mut new_path = output_path.to_path_buf(); + new_path.push(normalise_dirs(build_path_from_tag(tag))); + new_path = add_extension( + new_path, + path.extension().context("Track has no file extension")?, + ); + + if new_path == path { + return Ok(None); + } + + if !dry_run { + rename_creating_dirs(path, &new_path)?; + } + + Ok(Some(new_path)) +} + +pub fn rename_file(path: &Path, output_path: &Path, dry_run: bool) -> 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) = path + .extension() + .and_then(|e| e.to_str()) + .and_then(lofty::file::FileType::from_ext) + { + probe = probe.set_file_type(file_type); + } + + let tagged_file = probe + .read() + .with_context(|| format!("Failed to read tags from {}", path.display()))?; + + let tag = tagged_file.primary_tag().context("No primary tag found")?; + + rename_track(path, tag, output_path, dry_run) +}