commit 901745c27ec568193ab0ba1fe8af9165c0850bbd
parent fe0232cc55496ca5dff1adf147f7725c81f507fa
Author: mtmn <miro@haravara.org>
Date: Fri, 8 May 2026 11:33:25 +0200
chore: remove mack
Diffstat:
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)