Skip to content

Commit

Permalink
rename: Allow renaming based on custom format
Browse files Browse the repository at this point in the history
  • Loading branch information
cdown committed Apr 3, 2023
1 parent 7d0f75b commit 429084d
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 75 deletions.
60 changes: 60 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ lazy_static = "1.4.0"
regex = "1.7.3"
anyhow = "1.0.70"
id3 = "1.6.0"
funcfmt = "0.2.0"

[target.'cfg(target_family = "unix")'.dependencies]
libc = "0.2.140"
Expand Down
14 changes: 5 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# mack | [![Tests](https://img.shields.io/github/actions/workflow/status/cdown/mack/ci.yml?branch=master)](https://github.com/cdown/mack/actions?query=branch%3Amaster)

mack is to music files as [black][black] is to code formatting. It enforces
standards around both consistency of the metadata (eg. ID3 version) and the
metadata itself (eg. "feat" tagging).
mack enforces standards around both consistency of the metadata (eg. ID3
version) and the metadata itself (eg. "feat" tagging).

## 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 "{artist}/{album}/{track} {title}"
- Renaming files to format "{artist}/{album}/{track} {title}", or another
format specified with `--fmt`

## Usage

Expand Down Expand Up @@ -37,9 +37,5 @@ under most circumstances (0.2 seconds on the very first run).

## Configuration

In a similar philosophy to [black][black], most things cannot be configured --
you either use mack or you don't. There is one thing you can control though: if
you don't want a particular file to be touched by mack, add `_NO_MACK` as a
If you don't want a particular file to be touched by mack, add `_NO_MACK` as a
substring anywhere in the comment tag.

[black]: https://github.com/ambv/black
86 changes: 73 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ mod rename;
mod track;
mod types;

use anyhow::Result;
use clap::Parser;
use funcfmt::{fm, FormatMap, FormatPieces, ToFormatPieces};
use id3::TagLike;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use walkdir::WalkDir;

static ALLOWED_EXTS: &[&str] = &["mp3", "flac", "m4a"];
const ALLOWED_EXTS: &[&str] = &["mp3", "flac", "m4a"];

fn fix_track(track: &mut types::Track, dry_run: bool) {
let fix_results = fixers::run_fixers(track, dry_run);
Expand All @@ -36,8 +38,13 @@ fn print_updated_tags(track: &types::Track) {
);
}

fn rename_track(track: &types::Track, output_path: &Path, dry_run: bool) {
let new_path = rename::rename_track(track, output_path, dry_run);
fn rename_track(
track: &types::Track,
fp: &FormatPieces<types::Track>,
output_path: &Path,
dry_run: bool,
) {
let new_path = rename::rename_track(track, fp, output_path, dry_run);

match new_path {
Ok(Some(new_path)) => println!(
Expand All @@ -50,18 +57,71 @@ fn rename_track(track: &types::Track, output_path: &Path, dry_run: bool) {
}
}

// 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.
const MAX_PATH_PART_LEN: usize = 64;
const ADDITIONAL_ACCEPTED_CHARS: &[char] = &['.', '-', '(', ')', ','];

fn clean_part(path_part: &str) -> String {
let mut out: String = path_part
.chars()
.map(|c| {
if c.is_alphanumeric()
|| c.is_whitespace()
|| ADDITIONAL_ACCEPTED_CHARS.iter().any(|&a| a == c)
{
c
} else {
'_'
}
})
.collect();
out.truncate(MAX_PATH_PART_LEN);
out
}

fn get_format_pieces(tmpl: &str) -> Result<funcfmt::FormatPieces<types::Track>> {
let formatters: FormatMap<types::Track> = FormatMap::from([
fm!("artist", |t: &types::Track| Some(clean_part(
t.tag.artist().unwrap_or("Unknown Artist")
))),
fm!("album", |t: &types::Track| Some(clean_part(
t.tag.album().unwrap_or("Unknown Album")
))),
fm!("title", |t: &types::Track| Some(clean_part(
t.tag.title().unwrap_or("Unknown Title")
))),
fm!("track", |t: &types::Track| Some(format!(
"{:02}",
t.tag.track().unwrap_or_default()
))),
]);

Ok(formatters.to_format_pieces(tmpl)?)
}

fn is_updated_since_last_run(path: &PathBuf, last_run_time: SystemTime) -> bool {
mtime::mtime_def_now(path) > last_run_time
}

fn fix_all_tracks(base_path: &PathBuf, output_path: &Path, dry_run: bool, force: bool) {
fn fix_all_tracks(cfg: &types::Config, base_path: &PathBuf, output_path: &Path) {
// If the output path is different, we don't know if we should run or not, so just do them all
let last_run_time = if output_path == base_path {
mtime::get_last_run_time(base_path).unwrap_or(SystemTime::UNIX_EPOCH)
} else {
SystemTime::UNIX_EPOCH
};

let fp = match get_format_pieces(&cfg.fmt) {
Ok(fp) => fp,
Err(err) => {
eprintln!("fatal: {}", err);
std::panic::set_hook(Box::new(|_| {}));
panic!(); // Don't use exit() because it does not run destructors
}
};

let walker = WalkDir::new(base_path)
.into_iter()
.filter_map(std::result::Result::ok)
Expand All @@ -72,19 +132,19 @@ fn fix_all_tracks(base_path: &PathBuf, output_path: &Path, dry_run: bool, force:
.iter()
.any(|ext| &e.extension().and_then(OsStr::to_str).unwrap_or("") == ext)
})
.filter(|e| force || is_updated_since_last_run(e, last_run_time));
.filter(|e| cfg.force || is_updated_since_last_run(e, last_run_time));

for path in walker {
match track::get_track(path.clone()) {
Ok(mut track) => {
fix_track(&mut track, dry_run);
rename_track(&track, output_path, dry_run);
fix_track(&mut track, cfg.dry_run);
rename_track(&track, &fp, output_path, cfg.dry_run);
}
Err(err) => eprintln!("error: {}: {err:?}", path.display()),
}
}

if !dry_run && output_path == base_path {
if !cfg.dry_run && output_path == base_path {
mtime::set_last_run_time(base_path).unwrap_or_else(|err| {
eprintln!(
"can't set last run time for {}: {:?}",
Expand All @@ -96,23 +156,23 @@ fn fix_all_tracks(base_path: &PathBuf, output_path: &Path, dry_run: bool, force:
}

fn main() {
let args = types::Config::parse();
let cfg = types::Config::parse();

let paths = if args.paths.is_empty() {
let paths = if cfg.paths.is_empty() {
vec![PathBuf::from(".")]
} else {
args.paths
cfg.paths.clone()
};

for path in paths {
let this_output_path;

if let Some(op) = args.output_dir.clone() {
if let Some(op) = cfg.output_dir.clone() {
this_output_path = op;
} else {
this_output_path = path.clone();
}

fix_all_tracks(&path, &this_output_path, args.dry_run, args.force);
fix_all_tracks(&cfg, &path, &this_output_path);
}
}
70 changes: 17 additions & 53 deletions src/rename.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::types::Track;
use anyhow::{Context, Result};
use id3::TagLike;
use funcfmt::{FormatPieces, Render};
use std::fs;
use std::path::{Path, PathBuf};

Expand All @@ -9,56 +9,6 @@ use libc::EXDEV as xdev_err;
#[cfg(target_family = "windows")]
use winapi::shared::winerror::ERROR_NOT_SAME_DEVICE as xdev_err;

// 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.
const MAX_PATH_PART_LEN: usize = 64;

const ADDITIONAL_ACCEPTED_CHARS: &[char] = &['.', '-', '(', ')', ','];

fn sanitise_path_part(path_part: &str) -> String {
let mut out: String = path_part
.chars()
.map(|c| {
if c.is_alphanumeric()
|| c.is_whitespace()
|| ADDITIONAL_ACCEPTED_CHARS.iter().any(|&a| a == c)
{
c
} else {
'_'
}
})
.collect();
out.truncate(MAX_PATH_PART_LEN);
out
}

/// artist/album/2digitnum title.ext
fn make_relative_rename_path(track: &Track, output_path: &Path) -> PathBuf {
let tags = &track.tag;
let mut path = output_path.to_path_buf();

path.push(&sanitise_path_part(
tags.artist().unwrap_or("Unknown Artist"),
));
path.push(&sanitise_path_part(tags.album().unwrap_or("Unknown Album")));

let extension = track
.path
.extension()
.expect("BUG: ext required in walkbuilder, but missing");

let raw_filename = format!(
"{:02} {}.", // the extra "." is needed for .set_extension in case we already have a "."
tags.track().unwrap_or(0),
tags.title().unwrap_or("Unknown Title"),
);
path.push(&sanitise_path_part(&raw_filename));
path.set_extension(extension);
path
}

fn rename_creating_dirs(from: &PathBuf, to: &PathBuf) -> Result<()> {
fs::create_dir_all(to.parent().context("Refusing to move to FS root")?)?;

Expand All @@ -76,8 +26,22 @@ fn rename_creating_dirs(from: &PathBuf, to: &PathBuf) -> Result<()> {
Ok(())
}

pub fn rename_track(track: &Track, output_path: &Path, dry_run: bool) -> Result<Option<PathBuf>> {
let new_path = make_relative_rename_path(track, output_path);
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 = fp.render(track)?;
new_path.push(partial);

new_path.set_extension(
track
.path
.extension()
.context("ext required in walkbuilder, but missing")?,
);

if new_path == track.path {
return Ok(None);
Expand Down

0 comments on commit 429084d

Please sign in to comment.