tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

commit 901745c27ec568193ab0ba1fe8af9165c0850bbd
parent fe0232cc55496ca5dff1adf147f7725c81f507fa
Author: mtmn <miro@haravara.org>
Date:   Fri,  8 May 2026 11:33:25 +0200

chore: remove mack

Diffstat:
MCargo.lock | 314+------------------------------------------------------------------------------
MCargo.toml | 1-
MMODULE.bazel | 1-
Dmack/BUILD | 7-------
Dmack/Cargo.toml | 22----------------------
Dmack/LICENSE | 21---------------------
Dmack/README.md | 50--------------------------------------------------
Dmack/src/config.rs | 61-------------------------------------------------------------
Dmack/src/main.rs | 345-------------------------------------------------------------------------------
Dmack/src/prompt.rs | 43-------------------------------------------
Dmack/src/track/feat.rs | 251-------------------------------------------------------------------------------
Dmack/src/track/fixers.rs | 205-------------------------------------------------------------------------------
Dmack/src/track/loader.rs | 16----------------
Dmack/src/track/mod.rs | 15---------------
Dmack/src/track/rename.rs | 106-------------------------------------------------------------------------------
Dmack/src/util.rs | 6------
Dmack/todo.txt | 1-
17 files changed, 4 insertions(+), 1461 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,21 +3,6 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" @@ -80,12 +65,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] name = "aws-lc-rs" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -136,12 +115,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -275,77 +248,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "cow-utils" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] name = "csv" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -367,12 +269,6 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] name = "diffamer" version = "0.1.0" dependencies = [ @@ -402,12 +298,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -451,16 +341,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -488,18 +368,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "funcfmt" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e6db9acd7d06c3e871fcb6d232710e16e62c1be4db0922598c111f1e1ab30c2" -dependencies = [ - "fnv", - "smallvec", - "smartstring", - "thiserror 1.0.69", -] - -[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -879,7 +747,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror", "walkdir", "windows-link", ] @@ -939,16 +807,6 @@ dependencies = [ ] [[package]] -name = "jwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" -dependencies = [ - "crossbeam", - "rayon", -] - -[[package]] name = "lastfm_rs" version = "0.1.0" dependencies = [ @@ -984,32 +842,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "lofty" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca" -dependencies = [ - "byteorder", - "data-encoding", - "flate2", - "lofty_attr", - "log", - "ogg_pager", - "paste", -] - -[[package]] -name = "lofty_attr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1022,21 +854,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "mack" -version = "1.5.0" -dependencies = [ - "anyhow", - "clap", - "cow-utils", - "funcfmt", - "jwalk", - "libc", - "lofty", - "rayon", - "regex", -] - -[[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1049,16 +866,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] name = "mio" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1076,15 +883,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" [[package]] -name = "ogg_pager" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" -dependencies = [ - "byteorder", -] - -[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1103,12 +901,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1171,7 +963,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1193,7 +985,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1264,55 +1056,6 @@ dependencies = [ ] [[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[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" @@ -1580,12 +1323,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] name = "simd_cesu8" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1623,17 +1360,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - -[[package]] name = "socket2" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1650,12 +1376,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1734,31 +1454,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1953,12 +1653,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -3,5 +3,4 @@ resolver = "2" members = [ "diffamer", "lastfm_rs", - "mack", ] diff --git a/MODULE.bazel b/MODULE.bazel @@ -32,7 +32,6 @@ crate.from_cargo( cargo_lockfile = "//:Cargo.lock", manifests = [ "//:diffamer/Cargo.toml", - "//:mack/Cargo.toml", "//:lastfm_rs/Cargo.toml", ], ) diff --git a/mack/BUILD b/mack/BUILD @@ -1,7 +0,0 @@ -load("@crates//:defs.bzl", "all_crate_deps") -load("//bazel:rust.bzl", "rust_app") - -rust_app( - name = "mack", - deps = all_crate_deps(), -) diff --git a/mack/Cargo.toml b/mack/Cargo.toml @@ -1,22 +0,0 @@ -[package] -name = "mack" -version = "1.5.0" -edition = "2024" -authors = ["Chris Down <chris@chrisdown.name>"] -description = "An opinionated, fast music organiser." -repository = "https://github.com/cdown/mack" -readme = "README.md" -keywords = ["music", "audio", "organiser", "tagging", "mp3"] -categories = ["command-line-utilities", "filesystem"] -license = "MIT" - -[dependencies] -clap = { version = "4.5.54", features = ["std", "derive", "help"], default-features = false } -regex = "1.12.2" -anyhow = "1.0.100" -lofty = "0.24.0" -funcfmt = "0.3.0" -cow-utils = "0.1.3" -rayon = "1.11.0" -jwalk = "0.8.1" -libc = "0.2.180" diff --git a/mack/LICENSE b/mack/LICENSE @@ -1,21 +0,0 @@ -The MIT License - -Copyright (c) 2018-present Christopher Down - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/mack/README.md b/mack/README.md @@ -1,50 +0,0 @@ -# mack - -> [!NOTE] -> This is a fork of [github.com/cdown/mack](https://github.com/cdown/mack) - -mack is an opinionated, fast music organiser. It enforces: - -- Directory layout -- File name format -- Metadata consistency (e.g., consistent "feat" tagging) -- Format consistency (e.g., ID3 version) -- ...and more! - -## Examples of fixes - -- Moving featured artists from the artist tag to the title -- Enforcing a consistent "feat" format in title tags -- Whitespace normalisation -- Renaming files to format "{label}/{artist}/{album}/{track} {title}", or another - format specified with `--fmt` -- Reading tags `--read-tags` -- Writing tags `--write-tags` -- Adding a cover art `--add-cover` - -## Usage - -See `--help`. An example invocation is: - - % mack --dry-run -o Music . - 01 Pyramid.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/01 Pyramid.mp3 - 02 8films.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/02 8films.mp3 - 03 tobira.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/03 tobira.mp3 - 04 Compass.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/04 Compass.mp3 - 05 strings.mp3: renamed to Music/宇宙コンビニ/染まる音を確認したら/05 strings.mp3 - -You can see what would be changed first using `--dry-run`. - -## Installation - - cargo install mack - -## Performance - -mid-spec SSD, this means that we only take 0.005 seconds to run over ~3500 -files under most circumstances. - -## Configuration - -If you don't want a particular file to be touched by mack, add `_NO_MACK` as a -substring anywhere in the comment tag. diff --git a/mack/src/config.rs b/mack/src/config.rs @@ -1,61 +0,0 @@ -use clap::Parser; -use std::path::PathBuf; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[allow(clippy::struct_excessive_bools)] -pub struct Config { - #[arg( - long, - short = 'n', - help = "Don't actually rename or tag files, only display what would happen" - )] - pub dry_run: bool, - - #[arg( - long, - short, - help = "Use a different output directory (by default, it's the same as the input dir)" - )] - pub output_dir: Option<PathBuf>, - - /// The format to apply to files, excluding the extension. - /// - /// Substitutions can be applied inside curly brackets, for example with {artist} to get the - /// track artist. Any formats returning data with "/" will have it transformed to "_". - /// - /// Available formats: - /// - /// TAG: - /// - /// label - /// artist - /// album - /// track (width: 2) - /// title - /// - /// LITERAL: - /// - /// {{ and }} indicate literal brackets. - #[arg( - long, - verbatim_doc_comment, - default_value = "{label}/{artist}/{album}/{track} {title}" - )] - pub fmt: String, - - #[arg(help = "Directories to find music files in.")] - pub paths: Option<Vec<PathBuf>>, - - #[arg(long, short = 'r', help = "Read tags)")] - pub read_tags: bool, - - #[arg(long, short = 'w', help = "Write tags interactively")] - pub write_tags: bool, - - #[arg(long, short = 'c', help = "Add cover art)")] - pub add_cover: bool, - - #[arg(long, short = 'l', help = "Filter for files without a label")] - pub no_label: bool, -} diff --git a/mack/src/main.rs b/mack/src/main.rs @@ -1,345 +0,0 @@ -mod config; - -mod prompt; -mod track; -mod util; - -use anyhow::Result; -use clap::Parser; -use funcfmt::{FormatPieces, ToFormatPieces, fm}; -use jwalk::WalkDir; -use lofty::prelude::*; -use lofty::tag::{Accessor, ItemKey}; -use rayon::prelude::*; -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; - -use config::Config; -use track::{Track, get_track}; - -const ALLOWED_EXTS: &[&str] = &["mp3", "flac", "m4a"]; - -fn fix_track(track: &mut Track, dry_run: bool) { - let fix_results = track::fixers::run_fixers(track, dry_run); - match fix_results { - Ok(applied_fixers) => { - if applied_fixers { - print_updated_tags(track); - } - } - Err(err) => eprintln!("cannot fix {}: {:?}", track.path.display(), err), - } -} - -fn print_updated_tags(track: &Track) { - println!( - "{}: updated tags: artist: '{}', album: '{}', title: '{}', label: '{}'", - track.path.display(), - track.tag.artist().unwrap_or(std::borrow::Cow::Borrowed("")), - track.tag.album().unwrap_or(std::borrow::Cow::Borrowed("")), - track.tag.title().unwrap_or(std::borrow::Cow::Borrowed("")), - util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("")) - ); -} - -fn print_tags(track: &Track) { - let label = util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("Unknown Label")); - - println!( - "Folder: {}\nArtist: {}\nAlbum: {}\nLabel: {}\n", - track - .path - .parent() - .unwrap_or_else(|| Path::new(".")) - .display(), - track - .tag - .artist() - .unwrap_or(std::borrow::Cow::Borrowed("Unknown Artist")), - track - .tag - .album() - .unwrap_or(std::borrow::Cow::Borrowed("Unknown Album")), - label - ); -} - -fn rename_track(track: &Track, fp: &FormatPieces<Track>, output_path: &Path, dry_run: bool) { - let new_path = track::rename::rename_track(track, fp, output_path, dry_run); - - match new_path { - Ok(Some(new_path)) => println!( - "{}: renamed to {}", - track.path.display(), - new_path.display() - ), - Ok(None) => (), - Err(err) => eprintln!("cannot rename {}: {:?}", track.path.display(), err), - } -} - -const ADDITIONAL_ACCEPTED_CHARS: &[char] = &['.', '-', '(', ')', ',']; - -fn clean_part(path_part: &str) -> String { - path_part - .chars() - .map(|c| { - if c.is_alphanumeric() || c.is_whitespace() || ADDITIONAL_ACCEPTED_CHARS.contains(&c) { - c - } else { - '_' - } - }) - .collect() -} - -fn get_format_pieces(tmpl: &str) -> Result<funcfmt::FormatPieces<Track>> { - let formatters = fm!( - "artist" => |t: &Track| Some(clean_part(t.tag.artist().as_deref().unwrap_or("Unknown Artist"))), - "album" => |t: &Track| Some(clean_part(t.tag.album().as_deref().unwrap_or("Unknown Album"))), - "title" => |t: &Track| Some(clean_part(t.tag.title().as_deref().unwrap_or("Unknown Title"))), - "track" => |t: &Track| Some(format!("{:02}", t.tag.track().unwrap_or_default())), - "label" => |t: &Track| Some(clean_part(util::get_label(&t.tag).as_deref().unwrap_or("Unknown Label"))), - ); - - Ok(formatters.to_format_pieces(tmpl)?) -} - -fn run_write_tags(paths: &[PathBuf], no_label: bool) { - let mut by_folder: std::collections::HashMap<PathBuf, Vec<PathBuf>> = - std::collections::HashMap::new(); - for path in paths { - let parent = path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf(); - by_folder.entry(parent).or_default().push(path.clone()); - } - - // Sort folders for consistent UX - let mut parents: Vec<_> = by_folder.keys().cloned().collect(); - parents.sort(); - - for parent in parents { - let mut files = by_folder[&parent].clone(); - if files.is_empty() { - continue; - } - - if no_label { - files.retain(|path| { - if let Ok(track) = get_track(path.clone()) { - util::get_label(&track.tag).is_none() - } else { - false - } - }); - } - - if files.is_empty() { - continue; - } - - // Use first file to guess current tags - let first_path = &files[0]; - let first_track = match get_track(first_path.clone()) { - Ok(t) => t, - Err(e) => { - eprintln!("Error reading {}: {e:?}", first_path.display()); - continue; - } - }; - - // Prompt user once for the folder - println!("\nEditing Folder: {}", parent.display()); - let (new_artist, new_album, new_label) = prompt::prompt_tags(&first_track); - - // Apply to all files in folder if values are present via prompt_tags - for path in files { - match get_track(path.clone()) { - Ok(mut track) => { - let mut changed = false; - if let Some(ref a) = new_artist { - track.tag.set_artist(a.clone()); - changed = true; - } - if let Some(ref a) = new_album { - track.tag.set_album(a.clone()); - changed = true; - } - if let Some(ref l) = new_label - && util::get_label(&track.tag).is_none() - { - track.tag.insert_text(ItemKey::Label, l.clone()); - changed = true; - } - - if changed - && let Err(e) = track - .tag - .save_to_path(&track.path, lofty::config::WriteOptions::default()) - { - eprintln!("Error saving {}: {e:?}", track.path.display()); - } - } - Err(e) => eprintln!("Error reading {}: {e:?}", path.display()), - } - } - } -} - -fn run_read_tags(paths: &[PathBuf], no_label: bool) { - let mut seen_dirs = std::collections::HashSet::new(); - for path in paths { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - if seen_dirs.contains(parent) { - continue; - } - if let Ok(track) = get_track(path.clone()) { - if no_label && util::get_label(&track.tag).is_some() { - continue; - } - print_tags(&track); - seen_dirs.insert(parent.to_path_buf()); - } - } -} - -fn run_add_cover(paths: &[PathBuf]) { - paths.par_iter().for_each(|path| { - if let Ok(mut track) = get_track(path.clone()) - && let Err(e) = add_cover_art(&mut track) - { - eprintln!("Error adding cover for {}: {e:?}", path.display()); - } - }); -} - -fn run_fix_rename(cfg: &Config, output_path: &Path, paths: &[PathBuf]) { - let default_fmt = "{label}/{artist}/{album}/{track} {title}"; - let fallback_fmt = "{artist}/{album}/{track} {title}"; - - let fp_default = match get_format_pieces(&cfg.fmt) { - Ok(fp) => fp, - Err(err) => { - eprintln!("fatal: {err}"); - std::process::exit(1); - } - }; - - // Only compile fallback if we are using the default format - let fp_fallback = if cfg.fmt == default_fmt { - Some(match get_format_pieces(fallback_fmt) { - Ok(fp) => fp, - Err(err) => { - eprintln!("fatal: {err}"); - std::process::exit(1); - } - }) - } else { - None - }; - - paths - .par_iter() - .for_each(|path| match get_track(path.clone()) { - Ok(mut track) => { - fix_track(&mut track, cfg.dry_run); - - // Determine format to use - let format_to_use = if let Some(ref fp_fb) = fp_fallback { - if util::get_label(&track.tag).is_some() { - &fp_default - } else { - fp_fb - } - } else { - &fp_default - }; - - rename_track(&track, format_to_use, output_path, cfg.dry_run); - } - Err(err) => eprintln!("error: {}: {err:?}", path.display()), - }); -} - -fn fix_all_tracks(cfg: &Config, base_path: &PathBuf, output_path: &Path) { - let walker = WalkDir::new(base_path).skip_hidden(false); - let paths: Vec<PathBuf> = walker - .into_iter() - .filter_map(Result::ok) - .filter(|e| e.file_type().is_file()) - .map(|e| e.path()) - .filter(|e| { - let ext = e - .extension() - .and_then(OsStr::to_str) - .unwrap_or("") - .to_lowercase(); - ALLOWED_EXTS.iter().any(|a| a == &ext) - }) - .collect(); - - // Use sequential interaction for write_tags, otherwise parallel - if cfg.write_tags { - run_write_tags(&paths, cfg.no_label); - return; - } - - if cfg.read_tags { - run_read_tags(&paths, cfg.no_label); - return; - } - - if cfg.add_cover { - run_add_cover(&paths); - return; - } - - run_fix_rename(cfg, output_path, &paths); -} - -fn add_cover_art(track: &mut Track) -> Result<()> { - // Check for cover.jpg, cover.png, folder.jpg, folder.png in the same directory - let parent = track.path.parent().unwrap_or_else(|| Path::new(".")); - let candidates = ["cover.jpg", "cover.png", "folder.jpg", "folder.png"]; - - let mut cover_path = None; - for c in candidates { - let p = parent.join(c); - if p.exists() { - cover_path = Some(p); - break; - } - } - - if let Some(path) = cover_path { - println!( - "Adding cover art from {} to {}", - path.display(), - track.path.display() - ); - let mut picture = lofty::picture::Picture::from_reader(&mut std::fs::File::open(&path)?)?; - track - .tag - .remove_picture_type(lofty::picture::PictureType::CoverFront); - picture.set_pic_type(lofty::picture::PictureType::CoverFront); - track.tag.push_picture(picture); - track - .tag - .save_to_path(&track.path, lofty::config::WriteOptions::default())?; - } - - Ok(()) -} - -fn main() { - let mut cfg = Config::parse(); - - let paths = cfg.paths.take().unwrap_or_else(|| vec![PathBuf::from(".")]); - - for path in paths { - let output_path = cfg.output_dir.clone().unwrap_or_else(|| path.clone()); - fix_all_tracks(&cfg, &path, &output_path); - } -} diff --git a/mack/src/prompt.rs b/mack/src/prompt.rs @@ -1,43 +0,0 @@ -use lofty::tag::Accessor; -use std::io::{self, Write}; - -use crate::track::Track; - -fn prompt(prompt_text: &str) -> String { - print!("{prompt_text}"); - io::stdout().flush().unwrap(); - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap(); - input.trim().to_string() -} - -pub fn prompt_tags(track: &Track) -> (Option<String>, Option<String>, Option<String>) { - let current_artist = track.tag.artist().unwrap_or(std::borrow::Cow::Borrowed("")); - let new_artist_input = prompt(&format!("Artist [{current_artist}]: ")); - let new_artist = if new_artist_input.is_empty() { - None - } else { - Some(new_artist_input) - }; - - let current_album = track.tag.album().unwrap_or(std::borrow::Cow::Borrowed("")); - let new_album_input = prompt(&format!("Album [{current_album}]: ")); - let new_album = if new_album_input.is_empty() { - None - } else { - Some(new_album_input) - }; - - // Use common label detection logic - let current_label = - crate::util::get_label(&track.tag).unwrap_or(std::borrow::Cow::Borrowed("")); - - let new_label_input = prompt(&format!("Label [{current_label}]: ")); - let new_label = if new_label_input.is_empty() { - None - } else { - Some(new_label_input) - }; - - (new_artist, new_album, new_label) -} diff --git a/mack/src/track/feat.rs b/mack/src/track/feat.rs @@ -1,251 +0,0 @@ -use regex::Regex; - -const FEAT_KEYWORDS: &[&str] = &["feat", "ft", "f.", "featuring"]; - -/// Represents a track's title after extracting featured artists. -#[derive(Debug, PartialEq, Eq)] -pub struct TrackFeat { - pub title: String, - pub featured_artists: Vec<String>, - pub original_title: String, -} - -/// Holds the indices and inner content of a bracketed feature section. -struct BracketedFeat<'a> { - open_idx: usize, - close_idx: usize, - content: &'a str, -} - -/// Extracts featured artist information from a track title. -pub fn extract_feat(title: &str) -> TrackFeat { - if let Some(bf) = find_bracketed_feat(title, FEAT_KEYWORDS) { - let artist_part = remove_keyword_from_content(bf.content, FEAT_KEYWORDS); - let featured_artists = split_artists(artist_part); - let base_title = format!( - "{} {}", - &title[..bf.open_idx].trim_end(), - &title[bf.close_idx + 1..].trim_start() - ) - .trim() - .to_string(); - - return TrackFeat { - title: base_title, - featured_artists, - original_title: title.to_string(), - }; - } - - if let Some(pos) = find_non_bracketed_feat(title) { - let inner = &title[pos..].trim(); - let artist_part = remove_keyword_from_content(inner, FEAT_KEYWORDS); - let featured_artists = split_artists(artist_part); - let base_title = title[..pos].trim_end().to_string(); - return TrackFeat { - title: base_title, - featured_artists, - original_title: title.to_string(), - }; - } - - // No feature found, return as is - TrackFeat { - title: title.to_string(), - featured_artists: Vec::new(), - original_title: title.to_string(), - } -} - -/// Searches for a bracketed section (using '(' or '[') whose inner content, when trimmed -/// and lowercased, starts with one of the specified keywords. If found, returns a `BracketedFeat` -/// containing the opening index, closing index, and the inner content. -fn find_bracketed_feat<'a>(title: &'a str, keywords: &[&str]) -> Option<BracketedFeat<'a>> { - let mut chars = title.char_indices().peekable(); - while let Some((i, ch)) = chars.next() { - if ch == '(' || ch == '[' { - let closing = if ch == '(' { ')' } else { ']' }; - let mut depth = 1; - let mut j = i; - for (k, ch2) in chars.by_ref() { - if ch2 == ch { - depth += 1; - } else if ch2 == closing { - depth -= 1; - if depth == 0 { - j = k; - break; - } - } - } - if depth == 0 { - // Get the inner content (without the brackets). - let content = &title[i + ch.len_utf8()..j]; - if keywords - .iter() - .any(|&kw| content.trim().to_lowercase().starts_with(kw)) - { - return Some(BracketedFeat { - open_idx: i, - close_idx: j, - content, - }); - } - } - } - } - None -} - -/// Searches for a non-bracketed occurrence of any feature keyword (case–insensitively) -/// in the title. Returns the earliest index if found. -fn find_non_bracketed_feat(title: &str) -> Option<usize> { - static FEAT_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| { - let escaped_keywords: Vec<String> = - FEAT_KEYWORDS.iter().map(|&s| regex::escape(s)).collect(); - - let pattern = format!(r"(?i)(?:^|\W)({})(:?$|\W)", escaped_keywords.join("|")); - Regex::new(&pattern).expect("BUG: Invalid feat regex") - }); - - FEAT_RE - .captures(title) - .and_then(|caps| caps.get(1)) - .map(|m| m.start()) -} - -/// Given some content (either from inside a bracket or not) that starts with a feature keyword, -/// remove that keyword (choosing the longest matching one) and any immediately following period. -fn remove_keyword_from_content<'a>(content: &'a str, keywords: &[&str]) -> &'a str { - let trimmed = content.trim(); - let lower = trimmed.to_lowercase(); - if let Some(&kw) = keywords - .iter() - .filter(|&&k| lower.starts_with(k)) - .max_by_key(|k| k.len()) - { - let after = trimmed.get(kw.len()..).unwrap_or("").trim_start(); - if let Some(rest) = after.strip_prefix('.') { - rest.trim_start() - } else { - after - } - } else { - trimmed - } -} - -fn split_artists(artists_str: &str) -> Vec<String> { - let feat_artists: Vec<String> = artists_str - .split(',') - .flat_map(|s| s.split(" and ").flat_map(|s| s.split(" & "))) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - feat_artists -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_feat_no_feat() { - let given = "A plain title".to_owned(); - let expected = TrackFeat { - title: given.clone(), - featured_artists: Vec::new(), - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_no_feat_in_word() { - let given = "Lift Method".to_owned(); - let expected = TrackFeat { - title: given.clone(), - featured_artists: Vec::new(), - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_with_feat_single() { - let given = "A plain title feat. Foo Bar".to_owned(); - let expected = TrackFeat { - title: "A plain title".to_owned(), - featured_artists: vec!["Foo Bar".to_owned()], - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_with_feat_double() { - let given = "A plain title Ft. Foo Bar and Baz Qux".to_owned(); - let expected = TrackFeat { - title: "A plain title".to_owned(), - featured_artists: vec!["Foo Bar".to_owned(), "Baz Qux".to_owned()], - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_with_feat_as_f() { - let given = "A plain title f. Foo Bar and Baz Qux".to_owned(); - let expected = TrackFeat { - title: "A plain title".to_owned(), - featured_artists: vec!["Foo Bar".to_owned(), "Baz Qux".to_owned()], - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_with_feat_many() { - let given = "A plain title feat Foo Bar, Baz Qux, and Wibble Wobble".to_owned(); - let expected = TrackFeat { - title: "A plain title".to_owned(), - featured_artists: vec![ - "Foo Bar".to_owned(), - "Baz Qux".to_owned(), - "Wibble Wobble".to_owned(), - ], - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_with_surrounding_whitespace() { - let given = "A plain title feat Foo Bar, Baz Qux, and Wibble Wobble ".to_owned(); - let expected = TrackFeat { - title: "A plain title".to_owned(), - featured_artists: vec![ - "Foo Bar".to_owned(), - "Baz Qux".to_owned(), - "Wibble Wobble".to_owned(), - ], - original_title: given.clone(), - }; - assert_eq!(extract_feat(&given), expected); - } - - #[test] - fn test_extract_feat_nested_parentheses() { - let given = "比較大的大提琴 [Featuring Lara Veronin (梁心頤) & Gary Yang (楊瑞代)]"; - let expected = TrackFeat { - title: "比較大的大提琴".to_string(), - featured_artists: vec![ - "Lara Veronin (梁心頤)".to_string(), - "Gary Yang (楊瑞代)".to_string(), - ], - original_title: given.to_string(), - }; - assert_eq!(extract_feat(given), expected); - } -} diff --git a/mack/src/track/fixers.rs b/mack/src/track/fixers.rs @@ -1,205 +0,0 @@ -use crate::track::Track; -use crate::track::feat::{TrackFeat, extract_feat}; -use anyhow::{Result, bail}; -use cow_utils::CowUtils; -use lofty::config::WriteOptions; -use lofty::prelude::*; -use lofty::tag::{ItemKey, Tag}; - -use regex::Regex; - -static MULTI_WS_RE: std::sync::LazyLock<Regex> = - std::sync::LazyLock::new(|| Regex::new(r"[ \t]+").expect("BUG: Invalid regex")); - -pub fn run_fixers(track: &mut Track, dry_run: bool) -> Result<bool> { - let tags = &mut track.tag; - - fixer_is_blacklisted(tags)?; - - let new_title = fix_title(tags.title().as_deref(), tags.artist().as_deref()); - let new_artist = fix_artist(tags.artist().as_deref()); - let new_album = fix_album(tags.album().as_deref()); - let mut changed = false; - - if let Some(new_artist) = new_artist { - changed = true; - tags.set_artist(new_artist); - } - if let Some(new_title) = new_title { - changed = true; - tags.set_title(new_title); - } - if let Some(new_album) = new_album { - changed = true; - tags.set_album(new_album); - } - - if !dry_run && changed { - tags.save_to_path(&track.path, WriteOptions::default())?; - } - - Ok(changed) -} - -// False positive: https://github.com/rust-lang/rust-clippy/issues/12444 -#[allow(clippy::assigning_clones)] -fn normalise_field(field: &str) -> String { - let normalized = MULTI_WS_RE.replace_all(field, " ").to_string(); - let trimmed = normalized.trim().to_owned(); - trimmed - .cow_replace('[', "(") - .cow_replace(']', ")") - .cow_replace('…', "...") - .cow_replace('“', "\"") - .cow_replace('”', "\"") - .cow_replace('‘', "'") - .cow_replace('’', "'") - .to_string() -} - -fn fix_artist(old_artist: Option<&str>) -> Option<String> { - let field = normalise_field(old_artist.unwrap_or_default()); - let artist = extract_feat(&field); - if artist.title == artist.original_title { - None - } else { - Some(artist.title) - } -} - -fn fix_album(old_album: Option<&str>) -> Option<String> { - let old_album = old_album?; - let new_album = normalise_field(old_album); - - if new_album == old_album { - None - } else { - Some(new_album) - } -} - -fn fix_title(old_title: Option<&str>, old_artist: Option<&str>) -> Option<String> { - let old_title = old_title?; - let title_feat = extract_feat(old_title); - let artist_feat = extract_feat(old_artist.unwrap_or_default()); - let new_title = make_title(&title_feat, artist_feat); - - if new_title == title_feat.original_title { - None - } else { - Some(new_title) - } -} - -fn make_title(title: &TrackFeat, artist: TrackFeat) -> String { - let mut featured_artists = title.featured_artists.clone(); - featured_artists.extend(artist.featured_artists); - - let mut new_title = title.title.clone(); - if !featured_artists.is_empty() { - let feat_artists_string = make_feat_string(&featured_artists); - let feat_string = format!(" (feat. {feat_artists_string})"); - new_title.push_str(&feat_string); - } - - normalise_field(&new_title) -} - -fn make_feat_string(featured_artists: &[String]) -> String { - match featured_artists.len() { - 0 => String::new(), - 1 => featured_artists[0].clone(), - 2 => format!("{} and {}", featured_artists[0], featured_artists[1]), - _ => { - let head = &featured_artists[..featured_artists.len() - 1]; - let last = &featured_artists[featured_artists.len() - 1]; - format!("{}, and {}", head.join(", "), last) - } - } -} - -fn fixer_is_blacklisted(tags: &Tag) -> Result<()> { - for item in tags.items() { - if item.key() == ItemKey::Comment - && let Some(text) = item.value().text() - && text.contains("_NO_MACK") - { - bail!("Comment contains _NO_MACK"); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fix_artist_no_feat() { - let given = "Foo Bar"; - let expected = None; - assert_eq!(fix_artist(Some(given)), expected); - } - - #[test] - fn test_fix_artist_with_feat() { - let given = "Foo Bar (feat. Baz Qux)"; - let expected = Some("Foo Bar".to_owned()); - assert_eq!(fix_artist(Some(given)), expected); - } - - #[test] - fn test_fix_title_no_title_feat_no_artist_feat() { - let given_title = "Foo Bar"; - let given_artist = "Baz Qux"; - let expected = None; - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_title_with_title_feat_no_artist_feat() { - let given_title = "Foo Bar (feat. Wibble Wobble)"; - let given_artist = "Baz Qux"; - let expected = None; - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_title_with_title_feat_no_artist_feat_and_brackets() { - let given_title = "Foo Bar (feat. Wibble Wobble) [Richard Stallman mix]"; - let given_artist = "Baz Qux"; - let expected = Some("Foo Bar (Richard Stallman mix) (feat. Wibble Wobble)".to_owned()); - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_title_no_title_feat_with_artist_feat() { - let given_title = "Foo Bar"; - let given_artist = "Baz Qux feat. Fizz Buzz"; - let expected = Some("Foo Bar (feat. Fizz Buzz)".to_owned()); - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_title_with_title_feat_and_artist_feat() { - let given_title = "Foo Bar (feat. Wibble Wobble)"; - let given_artist = "Baz Qux feat. Fizz Buzz"; - let expected = Some("Foo Bar (feat. Wibble Wobble and Fizz Buzz)".to_owned()); - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_title_with_title_feat_smart_quotes() { - let given_title = "Foo ‘Bar’ (feat. Wibble “Wabble” Wobble)"; - let given_artist = "Baz Qux"; - let expected = Some("Foo 'Bar' (feat. Wibble \"Wabble\" Wobble)".to_owned()); - assert_eq!(fix_title(Some(given_title), Some(given_artist)), expected); - } - - #[test] - fn test_fix_whitespace() { - let given = " Foo Bar [feat. Baz Qux ] "; - let expected = Some("Foo Bar (feat. Baz Qux)".to_owned()); - assert_eq!(fix_title(Some(given), None), expected); - } -} diff --git a/mack/src/track/loader.rs b/mack/src/track/loader.rs @@ -1,16 +0,0 @@ -use crate::track::Track; -use anyhow::Result; -use lofty::prelude::*; -use lofty::probe::Probe; -use lofty::tag::Tag; -use std::path::PathBuf; - -pub fn get_track(path: PathBuf) -> Result<Track> { - let tagged_file = Probe::open(&path)?.read()?; - let tag = tagged_file - .primary_tag() - .cloned() - .or_else(|| tagged_file.first_tag().cloned()) - .unwrap_or_else(|| Tag::new(tagged_file.file_type().primary_tag_type())); - Ok(Track { path, tag }) -} diff --git a/mack/src/track/mod.rs b/mack/src/track/mod.rs @@ -1,15 +0,0 @@ -pub mod feat; -pub mod fixers; -pub mod loader; -pub mod rename; - -pub use loader::get_track; - -use lofty::tag::Tag; -use std::path::PathBuf; - -/// Represents a music track with its file path and associated ID3 tag. -pub struct Track { - pub path: PathBuf, - pub tag: Tag, -} diff --git a/mack/src/track/rename.rs b/mack/src/track/rename.rs @@ -1,106 +0,0 @@ -use crate::track::Track; -use anyhow::{Context, Result}; -use funcfmt::{FormatPieces, Render}; - -use regex::Regex; -use std::ffi::{OsStr, OsString}; -use std::fs; -use std::path::{Path, PathBuf}; - -#[cfg(target_family = "unix")] -use libc::EXDEV as xdev_err; - -fn rename_creating_dirs(from: &PathBuf, to: &PathBuf) -> Result<()> { - fs::create_dir_all(to.parent().context("Refusing to move to FS root")?)?; - - // Trying to rename cross device? Just copy and unlink the old one - if let Err(err) = fs::rename(from, to) { - if err.raw_os_error() == Some(xdev_err) { - fs::copy(from, to)?; - fs::remove_file(from)?; - } else { - Err(err)?; - } - } - Ok(()) -} - -/// `String::truncate` will panic if not at a char boundary -fn safe_truncate(s: &mut String, max_chars: usize) { - if let Some((idx, _)) = s.char_indices().nth(max_chars) { - s.truncate(idx); - } -} - -// Arbitrary limit on path part without extension to try to avoid brushing against PATH_MAX. We -// can't just check PATH_MAX and similar, because we also want to avoid issues when copying -// elsewhere later. -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 { - let partial = PathBuf::from(path_part); - partial - .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); - - // Illegal characters on Linux are only / (handled by components) and null byte (handled by Rust string types) - s = s.replace('/', "_"); - - // Trim leading/trailing whitespace, which can be problematic on some filesystems. - s = s.trim().to_string(); - - // exfat normalises this and it confuses adb-sync and other tooling - s = MULTI_DOT_RE.replace_all(&s, ".").to_string(); - - // Disallow leading dots to prevent creating hidden files/directories. - let s = s.trim_start_matches('.').to_string(); - - // If the component is now empty (e.g. it was just "."), use a placeholder. - 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() -} - -pub fn rename_track( - track: &Track, - fp: &FormatPieces<Track>, - output_path: &Path, - dry_run: bool, -) -> Result<Option<PathBuf>> { - let mut new_path = output_path.to_path_buf(); - let partial = normalise_dirs(fp.render(track)?); - new_path.push(partial); - - // We might have truncated and have a dot elsewhere, so we can't use set_extension - new_path = add_extension( - new_path, - track - .path - .extension() - .context("ext required in walkbuilder, but missing")?, - ); - - if new_path == track.path { - return Ok(None); - } - - if !dry_run { - rename_creating_dirs(&track.path, &new_path)?; - } - - Ok(Some(new_path)) -} diff --git a/mack/src/util.rs b/mack/src/util.rs @@ -1,6 +0,0 @@ -use lofty::tag::{ItemKey, Tag}; -use std::borrow::Cow; - -pub fn get_label(tag: &Tag) -> Option<Cow<'_, str>> { - tag.get_string(ItemKey::Label).map(Cow::Borrowed) -} diff --git a/mack/todo.txt b/mack/todo.txt @@ -1 +0,0 @@ -- if label exists, it doesnt change when using `-w` (introduced in last commit)