commit - cd83e1901ec0458a665c86ebb869f2bc1f6320a7
commit + 82f71cb083b970d6f5729b00ec6f1ad410a867a9
blob - 4591c2737cb0e0d838896ae923b069ca82a2f0b5
blob + a3ffd295f77470bd6dc404e7acb902a4c1a19adf
--- Cargo.lock
+++ Cargo.lock
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"
"anyhow",
"clap",
"lofty",
+ "regex",
"reqwest",
"serde",
"serde_json",
]
[[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
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
* 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
* `--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.
# 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
mod context;
mod fetchers;
mod metadata;
+mod rename;
mod tagging;
use anyhow::Result;
#[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>,
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
+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)
+}