diff --git a/Cargo.lock b/Cargo.lock index d03c550..b6c789d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.11" @@ -63,6 +72,9 @@ dependencies = [ "anyhow", "clap", "mp4", + "pretty_assertions", + "regex", + "rstest", "taglib", ] @@ -84,6 +96,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + [[package]] name = "clap" version = "4.4.18" @@ -130,12 +148,141 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.9" @@ -148,6 +295,12 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "mp4" version = "0.14.0" @@ -205,6 +358,37 @@ dependencies = [ "autocfg", ] +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.66" @@ -223,12 +407,92 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "ryu" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.188" @@ -260,6 +524,15 @@ dependencies = [ "serde", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "strsim" version = "0.10.0" @@ -316,6 +589,23 @@ dependencies = [ "syn", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.11" @@ -393,3 +683,18 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52ac009d615e79296318c1bcce2d422aaca15ad08515e344feeda07df67a587" +dependencies = [ + "memchr", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index 54b3784..3e7d2cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,12 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.75" clap = { version = "4.4", features = ["derive", "cargo"] } mp4 = "0.14.0" +regex = "1.10" taglib = "1.0.0" -anyhow = "1.0.75" + +[dev-dependencies] +pretty_assertions = "1.4" +rstest = "0.22" diff --git a/src/main.rs b/src/main.rs index 2757203..b7cc25a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,8 @@ +// + +#[cfg(test)] +mod tests; + use anyhow::{Context, Result}; use clap::{crate_version, Parser}; use std::{ @@ -44,7 +49,8 @@ fn main() { fn rename_files_in_directory(directory: String, args: &Arguments) { println!("Renaming files in {}", directory); match rename_files(&directory, &directory, args) { - Ok(count) => println!("Renamed {} files", count), + Ok(count) if args.not_dry_run() => println!("Renamed {} files", count), + Ok(_) => println!("Dry-Run complete"), Err(e) => eprintln!("Error: {}", e), } } @@ -60,43 +66,72 @@ fn rename_files(directory: &str, base: &str, args: &Arguments) -> Result { count += rename_files(sub_dir, base, args).context("Failed to rename files")?; } } - if path.is_file() { - if let Ok(file) = taglib::File::new(&path) { - if let Ok(tag) = file.tag() { - if let Some(title) = tag.title() { - if let Some(artist) = tag.artist() { - let album = parse_album(&title); - count += 1; - let new_name = match album.len() { - 0 => format!("{artist}/{title}/{title}.m4b"), - _ => format!("{artist}/{album}/{title}/{title}.m4b"), - }; - println!("Renaming {} to {}", path.display(), new_name); - let target_path = Path::new(&base).join(new_name); - if !target_path.exists() { - let dir = target_path.parent().with_context(|| { - format!("Failed to get parent: {:#?}", target_path) - })?; - if args.not_dry_run() { - create_dir_all(dir).with_context(|| { - format!("Failed to create directory: {:#?}", dir) - })?; - } - } - if args.not_dry_run() { - rename(&path, &target_path).with_context(|| { - format!("Failed to rename file: {:#?}", target_path) - })?; - } - } - } - } + if !path.is_file() { + continue; + } + let Ok(file) = taglib::File::new(&path) else { + continue; + }; + let Ok(tag) = file.tag() else { + continue; + }; + let (Some(title), Some(artist)) = (tag.title(), tag.artist()) else { + continue; + }; + let album = parse_album(&title); + count += 1; + let new_name = if album.is_empty() { + format!("{artist}/{title}/{title}.m4b") + } else { + build_series_name(&artist, &album, &title) + }; + println!("=============================="); + println!("- artist: {artist}"); + println!("- album : {album}"); + println!("- title : {title}"); + println!("> {new_name}"); + if args.not_dry_run() { + let target_path = Path::new(&base).join(new_name); + if !target_path.exists() { + let dir = target_path + .parent() + .with_context(|| format!("Failed to get parent: {:#?}", target_path))?; + create_dir_all(dir) + .with_context(|| format!("Failed to create directory: {:#?}", dir))?; } + + rename(&path, &target_path) + .with_context(|| format!("Failed to rename file: {:#?}", target_path))?; } } Ok(count) } +fn build_series_name(artist: &str, album: &str, title: &str) -> String { + let index = parse_index(title); + let title = parse_title(title); + format!("{artist}/{album}/{index}. {title}/{index}. {title}.m4b") +} + +fn parse_index(title: &str) -> String { + if let Ok(index_re) = regex::Regex::new(r", Book (?P\d+)") { + if let Some(captures) = index_re.captures(title) { + let index = captures.name("INDEX").map_or("", |m| m.as_str()); + return index.to_string(); + } + } + String::new() +} +fn parse_title(title: &str) -> String { + if let Ok(title_re) = regex::Regex::new(r"^(?P.*?):") { + if let Some(captures) = title_re.captures(title) { + let title = captures.name("TITLE").map_or("", |m| m.as_str()); + return title.to_string(); + } + } + title.to_string() +} + fn parse_album(title: &str) -> String { let parts: Vec<&str> = title.split(':').collect(); match &parts[..] { diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..2f59c16 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,18 @@ +// +use super::*; + +use pretty_assertions::assert_eq; + +#[rstest::rstest] +#[case("Discworld", "The Truth: Discworld, Book 25", "25. The Truth")] +#[case( + "A Darker Shade of Magic", + "A Conjuring of Light: A Darker Shade of Magic, Book 3", + "3. A Conjuring of Light" +)] +fn parse_series_details(#[case] album: &str, #[case] title: &str, #[case] expected: &str) { + let expected_result = format!("Bob/{album}/{expected}/{expected}.m4b"); + let result = build_series_name("Bob", album, title); + + assert_eq!(result, expected_result); +}