Commit Diff


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 <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 <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<std::path::PathBuf>,
+
+    /// 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<String>,
@@ -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<Regex> =
+    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<OsStr>) -> 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<Option<PathBuf>> {
+    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<Option<PathBuf>> {
+    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)
+}