replaygain support

This commit is contained in:
ChaotiCryptidz 2022-10-22 21:01:36 +01:00
parent 8c17f237e8
commit f49647d622
No known key found for this signature in database
15 changed files with 459 additions and 71 deletions

2
Cargo.lock generated
View file

@ -538,7 +538,9 @@ dependencies = [
"clap",
"crossbeam",
"html-escape",
"id3",
"lazy_static",
"metaflac",
"notify",
"serde",
"serde_json",

View file

@ -23,6 +23,8 @@ walkdir = "2.3.2"
# tag reading
audiotags = "0.4.1"
id3 = "1.3.0"
metaflac = "0.2.5"
# for genhtml command
html-escape = "0.2.11"

View file

@ -17,52 +17,51 @@ pub enum Commands {
#[derive(Debug, Args)]
pub struct ProcessCommandArgs {
#[clap(value_parser)]
pub source: String,
#[clap(long, value_parser)]
#[clap(long)]
pub dry_run: bool,
#[clap(long)]
pub skip_replaygain: bool,
#[clap(long)]
pub force_replaygain: bool,
#[clap(long)]
pub replaygain_threads: Option<u32>,
}
#[derive(Debug, Args)]
pub struct GenHTMLCommandArgs {
#[clap(value_parser)]
pub source: String,
#[clap(value_parser)]
pub dest: String,
#[clap(long, value_parser, default_value = "musicutil")]
#[clap(long, default_value = "musicutil")]
pub title: String,
#[clap(long, value_parser, default_value = "generated by musicutil")]
#[clap(long, default_value = "generated by musicutil")]
pub description: String,
}
#[derive(Debug, Args)]
pub struct TranscodeCommandArgs {
#[clap(value_parser)]
pub source: String,
#[clap(value_parser)]
pub dest: String,
#[clap(long, value_parser)]
#[clap(long)]
pub transcode_preset: Option<String>,
#[clap(long, value_parser)]
#[clap(long)]
pub transcode_config: Option<String>,
#[clap(long, value_parser)]
#[clap(long)]
pub ignore_extension: bool,
}
#[derive(Debug, Clone, Args)]
pub struct CopyCommandArgs {
#[clap(value_parser)]
pub source: String,
#[clap(value_parser)]
pub dest: String,
#[clap(long, value_parser)]
#[clap(long)]
pub transcode_preset: Option<String>,
#[clap(long, value_parser)]
#[clap(long)]
pub transcode_config: Option<String>,
#[clap(long, value_parser)]
#[clap(long)]
pub threads: Option<u32>,
#[clap(long, value_parser)]
#[clap(long)]
pub no_skip_existing: bool,
#[clap(long, value_parser)]
#[clap(long)]
pub single_directory: bool,
}

View file

@ -13,8 +13,7 @@ use crate::{
args::{CLIArgs, CopyCommandArgs},
types::File,
utils::{
scan_for_music,
extract_tags_from_file,
extract_tags_from_file, scan_for_music,
transcoder::{
presets::{print_presets, transcode_preset_or_config},
transcode,
@ -104,30 +103,41 @@ pub fn copy_command(
Ok(())
}
fn copy_file(file: &File, copy_args: &CopyCommandArgs) -> Result<(), Box<dyn std::error::Error>> {
let from_path = file.join_path_to();
let to_path_dest = PathBuf::from_str(copy_args.dest.as_str()).unwrap();
let to_path = match copy_args.single_directory {
true => to_path_dest.join(file.join_filename()),
false => to_path_dest
.join(file.path_from_source.clone())
.join(file.join_filename()),
};
let to_path_string = to_path.as_os_str().to_str().unwrap().to_string();
if !copy_args.no_skip_existing && to_path.exists() {
println!(
"Skipping {} as already exists in destination",
to_path_string
);
} else {
println!("Copying {:?} to {}", from_path, to_path_string);
fs::copy(from_path, to_path)?;
}
Ok(())
}
fn copy_files(
files: &[File],
copy_args: &CopyCommandArgs,
) -> Result<(), Box<dyn std::error::Error>> {
for file in files.iter() {
let from_path = file.join_path_to();
copy_file(file, copy_args)?;
let to_path_dest = PathBuf::from_str(copy_args.dest.as_str()).unwrap();
let to_path = match copy_args.single_directory {
true => to_path_dest.join(file.join_filename()),
false => to_path_dest
.join(file.path_from_source.clone())
.join(file.join_filename()),
};
let to_path_string = to_path.as_os_str().to_str().unwrap().to_string();
if !copy_args.no_skip_existing && to_path.exists() {
println!(
"Skipping {} as already exists in destination",
to_path_string
);
} else {
println!("Copying {:?} to {}", from_path, to_path_string);
fs::copy(from_path, to_path)?;
if !file.extra_files.is_empty() {
for extra_file in file.extra_files.iter() {
copy_file(extra_file, copy_args)?;
}
}
}
@ -156,6 +166,12 @@ fn transcode_file(
};
let to_path_string = to_path.as_os_str().to_str().unwrap().to_string();
if !file.extra_files.is_empty() {
for extra_file in file.extra_files.iter() {
copy_file(extra_file, copy_args)?;
}
}
if !copy_args.no_skip_existing && to_path.exists() {
println!(
"Skipping transcode for {} as file already exists",
@ -193,7 +209,14 @@ fn transcode_files(
scope(|s| {
for _ in 0..copy_args.threads.unwrap() {
s.spawn(|_| loop {
let job = jobs.lock().unwrap().pop().unwrap().clone();
let mut jobs = jobs.lock().unwrap();
let job = jobs.pop();
if job.is_none() {
break;
}
let job = job.unwrap().clone();
drop(jobs);
let result = transcode_file(
&job,
&copy_args_arc,

View file

@ -1,8 +1,8 @@
use crate::args::CLIArgs;
use crate::args::GenHTMLCommandArgs;
use crate::types::File;
use crate::utils::scan_for_music;
use crate::utils::extract_tags_from_file;
use crate::utils::scan_for_music;
use std::cmp::Ordering;
use std::io::Write;
@ -100,7 +100,7 @@ pub fn genhtml_command(
if a.tags.title != b.tags.title {
return a.tags.title.cmp(&b.tags.title);
}
a.tags.artist.cmp(&b.tags.artist)
});

View file

@ -1,9 +1,16 @@
use std::sync::Arc;
use std::sync::Mutex;
use crossbeam::scope;
use crate::args::CLIArgs;
use crate::args::ProcessCommandArgs;
use crate::types::File;
use crate::utils::ascii_reduce::reduce_to_ascii;
use crate::utils::scan_for_music;
use crate::utils::extract_tags_from_file;
use crate::utils::replaygain::analyze_replaygain;
use crate::utils::replaygain::tag_replaygain;
use crate::utils::scan_for_music;
fn rename_file(_args: &CLIArgs, process_args: &ProcessCommandArgs, file: &mut File) {
let title = &file.tags.title;
@ -36,8 +43,13 @@ fn rename_file(_args: &CLIArgs, process_args: &ProcessCommandArgs, file: &mut Fi
let mut new_file = file.clone();
new_file.filename = filename.clone();
let extension = &file.extension;
// Step 6: Rename File
println!("Renaming File from {} to {}", file.filename, filename);
println!(
"Renaming File from {}.{extension} to {}.{extension}",
file.filename, filename
);
if !process_args.dry_run {
if std::path::Path::new(&new_file.join_path_to()).exists() {
panic!(
@ -53,11 +65,75 @@ fn rename_file(_args: &CLIArgs, process_args: &ProcessCommandArgs, file: &mut Fi
}
}
if !file.extra_files.is_empty() {
let mut new_extra_files: Vec<File> = Vec::new();
for extra_file in file.extra_files.iter() {
let mut new_extra_file = extra_file.clone();
new_extra_file.filename = filename.clone();
let extra_extension = &extra_file.extension;
println!(
"Renaming Extra File from {}.{extra_extension} to {}.{extra_extension}",
file.filename, filename
);
if !process_args.dry_run {
let err =
std::fs::rename(&extra_file.join_path_to(), &new_extra_file.join_path_to());
if err.is_err() {
panic!("Could not rename {:?}", err)
}
new_extra_files.push(new_extra_file);
}
}
if !process_args.dry_run {
file.extra_files.clear();
file.extra_files.extend(new_extra_files);
}
}
if !process_args.dry_run {
file.filename = filename;
}
}
pub fn add_replaygain_tags(file: &File, force: bool) -> Result<(), Box<dyn std::error::Error>> {
if !file.tags.supports_replaygain {
println!(
"Skipping replaygain for {:?}, not supported",
file.join_path_from_source()
);
return Ok(());
}
if file.tags.contains_replaygain_tags && !force {
println!(
"Skipping replaygain for {:?}, contains already",
file.join_path_from_source()
);
return Ok(());
}
println!(
"Analyzing replaygain for {:?}",
file.join_path_from_source()
);
let replaygain_data = analyze_replaygain(file.join_path_to())?;
tag_replaygain(file.join_path_to(), replaygain_data)?;
println!(
"Applied replaygain tags for {:?}",
file.join_path_from_source()
);
Ok(())
}
pub fn process_command(
args: &CLIArgs,
process_args: &ProcessCommandArgs,
@ -78,5 +154,40 @@ pub fn process_command(
rename_file(args, process_args, file);
}
if !process_args.skip_replaygain && !process_args.dry_run {
println!("Adding ReplayGain Tags to Files");
if process_args.replaygain_threads.is_some() && process_args.replaygain_threads.unwrap() > 1
{
let files_copy = files.to_vec();
let jobs: Arc<Mutex<Vec<File>>> = Arc::new(Mutex::new(files_copy));
scope(|s| {
for _ in 0..process_args.replaygain_threads.unwrap() {
s.spawn(|_| loop {
let mut jobs = jobs.lock().unwrap();
let job = jobs.pop();
if job.is_none() {
break;
}
let job = job.unwrap().clone();
drop(jobs);
let result = add_replaygain_tags(&job, process_args.force_replaygain);
if result.is_err() {
panic!("Error doing replaygain: {}", result.unwrap_err())
}
});
}
})
.expect("threads haunted");
} else {
for file in files.iter_mut() {
add_replaygain_tags(file, process_args.force_replaygain)?;
}
}
}
Ok(())
}

View file

@ -14,7 +14,6 @@ pub fn transcode_command(
_args: &CLIArgs,
transcode_args: &TranscodeCommandArgs,
) -> Result<(), Box<dyn std::error::Error>> {
if transcode_args.transcode_config.is_none() && transcode_args.transcode_preset.is_none() {
panic!("Please provide Transcode Preset/Config");
}
@ -50,8 +49,7 @@ pub fn transcode_command(
"please change it to {} ",
"or run with --ignore-extension"
),
output_file.extension,
file_extension
output_file.extension, file_extension
);
}
}

View file

@ -4,6 +4,9 @@ use std::path::PathBuf;
pub struct Tags {
pub title: String,
pub artist: String,
pub supports_replaygain: bool,
pub contains_replaygain_tags: bool,
}
#[derive(Debug, Clone)]
@ -11,6 +14,8 @@ pub struct File {
pub filename: String,
pub extension: String,
pub extra_files: Vec<File>,
pub path_to: PathBuf,
pub path_from_source: PathBuf,
pub tags: Tags,
@ -44,9 +49,12 @@ impl File {
extension,
path_from_source: folder_path_from_src,
path_to,
extra_files: Vec::new(),
tags: Tags {
title: "".to_string(),
artist: "".to_string(),
supports_replaygain: false,
contains_replaygain_tags: false,
},
}
}

View file

@ -1,13 +1,13 @@
use std::path::{Path};
use std::path::Path;
pub mod ascii_reduce;
pub mod transcoder;
pub(self) mod tag_extractor;
pub(self) mod music_scanner;
pub mod replaygain;
pub(self) mod tag_extractor;
pub mod transcoder;
pub use tag_extractor::extract_tags_from_file;
pub use music_scanner::scan_for_music;
pub use tag_extractor::extract_tags_from_file;
pub fn is_supported_file_extension(file_path: &Path) -> bool {
let ext = file_path.extension().unwrap().to_str().unwrap();

View file

@ -1,8 +1,32 @@
use std::fs;
use crate::types::File;
use walkdir::WalkDir;
use super::is_supported_file_extension;
pub fn find_extra_files(
src_dir: String,
file: &File,
) -> Result<Vec<File>, Box<dyn std::error::Error>> {
let mut extra_files: Vec<File> = Vec::new();
for entry in fs::read_dir(&file.path_to)? {
let entry = entry?;
if !entry.metadata()?.is_file() {
continue;
}
let entry_path = entry.path();
if entry_path.file_stem().unwrap().to_string_lossy() == file.filename
&& entry_path.extension().unwrap().to_string_lossy() != file.extension
{
extra_files.push(File::from_path(src_dir.clone(), entry_path.clone()));
}
}
Ok(extra_files)
}
pub fn scan_for_music(src_dir: &String) -> Result<Vec<File>, Box<dyn std::error::Error>> {
let mut files: Vec<File> = Vec::new();
@ -14,10 +38,11 @@ pub fn scan_for_music(src_dir: &String) -> Result<Vec<File>, Box<dyn std::error:
}
if is_supported_file_extension(&entry_path) {
let file = File::from_path(
src_dir.clone(),
entry_path,
);
let mut file = File::from_path(src_dir.clone(), entry_path.clone());
file.extra_files
.extend(find_extra_files(src_dir.clone(), &file)?);
files.push(file);
}
}

182
src/utils/replaygain/mod.rs Normal file
View file

@ -0,0 +1,182 @@
use std::{
io::{BufRead, BufReader},
path::PathBuf,
process::Command,
};
use id3::TagLike;
use string_error::static_err;
#[derive(Debug, Clone)]
pub struct ReplayGainData {
pub track_gain: f64,
pub track_peak: f64,
}
pub fn analyze_replaygain(path: PathBuf) -> Result<ReplayGainData, Box<dyn std::error::Error>> {
let output = Command::new(crate::meta::FFMPEG)
.args([
"-hide_banner",
"-nostats",
"-v",
"info",
"-i",
&path.to_string_lossy(),
"-filter_complex",
"[0:a]ebur128=framelog=verbose:peak=sample:dualmono=true[s6]",
"-map",
"[s6]",
"-f",
"null",
"/dev/null",
])
.output()?;
if !output.status.success() {
print!("{:?}", String::from_utf8(output.stderr).unwrap());
return Err(static_err("FFmpeg Crashed"));
}
// info we need is in stdout
let output_str = String::from_utf8(output.stderr).unwrap();
let mut ebur128_summary = String::new();
// for some reason ffmpeg outputs the summary twice,
// the first time is garbage data
let mut has_seen_first_summary = false;
let mut should_start_reading_lines = false;
let output_reader = BufReader::new(output_str.as_bytes());
for line in output_reader.lines() {
if let Ok(line) = line {
if line.starts_with("[Parsed_ebur128_0") {
if has_seen_first_summary {
should_start_reading_lines = true;
ebur128_summary.push_str("Summary:\n")
} else {
has_seen_first_summary = true;
}
} else if should_start_reading_lines {
ebur128_summary.push_str(line.trim());
ebur128_summary.push('\n')
}
} else {
break;
}
}
let mut track_gain: f64 = 0.0;
let mut track_peak: f64 = 0.0;
let summary_reader = BufReader::new(ebur128_summary.as_bytes());
for line in summary_reader.lines() {
if let Ok(line) = line {
if line.starts_with("I:") {
let mut l = line.split(':');
l.next();
let gain = l.next().unwrap().trim().trim_end_matches(" LUFS");
let gain = gain.parse::<f64>()?;
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Gain_calculation
// "In order to maintain backwards compatibility with RG1, RG2 uses a -18 LUFS reference, which based on lots of music, can give similar loudness compared to RG1."
let gain = -18_f64 - gain;
track_gain = gain;
}
if line.starts_with("Peak:") {
let mut l = line.split(':');
l.next();
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#Loudness_normalization
let peak = l.next().unwrap().trim().trim_end_matches(" dBFS");
let peak = peak.parse::<f64>()?;
let peak = f64::powf(10_f64, peak / 20.0_f64);
track_peak = peak;
}
} else {
break;
}
}
Ok(ReplayGainData {
track_gain,
track_peak,
})
}
pub fn tag_replaygain_mp3(
path: PathBuf,
data: ReplayGainData,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tag = id3::Tag::read_from_path(&path)?;
let frames = tag.remove("TXXX");
for frame in frames {
if let Some(extended_text) = frame.content().extended_text() {
if extended_text.description.starts_with("REPLAYGAIN") {
continue;
}
}
tag.add_frame(frame);
}
tag.add_frame(id3::Frame::with_content(
"TXXX",
id3::Content::ExtendedText(id3::frame::ExtendedText {
description: "REPLAYGAIN_TRACK_GAIN".to_string(),
value: format!("{:.2} dB", data.track_gain),
}),
));
tag.add_frame(id3::Frame::with_content(
"TXXX",
id3::Content::ExtendedText(id3::frame::ExtendedText {
description: "REPLAYGAIN_TRACK_PEAK".to_string(),
value: format!("{:.6}", data.track_peak),
}),
));
tag.write_to_path(path, id3::Version::Id3v24)?;
Ok(())
}
pub fn tag_replaygain_flac(
path: PathBuf,
data: ReplayGainData,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tag = metaflac::Tag::read_from_path(&path)?;
tag.remove_vorbis("REPLAYGAIN_TRACK_GAIN");
tag.remove_vorbis("REPLAYGAIN_TRACK_PEAK");
tag.set_vorbis(
"REPLAYGAIN_TRACK_GAIN",
vec![format!("{:.2} dB", data.track_gain)],
);
tag.set_vorbis(
"REPLAYGAIN_TRACK_PEAK",
vec![format!("{:.6}", data.track_peak)],
);
tag.write_to_path(path)?;
Ok(())
}
pub fn tag_replaygain(
path: PathBuf,
data: ReplayGainData,
) -> Result<(), Box<dyn std::error::Error>> {
let extension = path.extension().unwrap().to_string_lossy().to_string();
if extension == "mp3" {
return tag_replaygain_mp3(path, data);
} else if extension == "flac" {
return tag_replaygain_flac(path, data);
}
panic!("extension not tagable");
}

View file

@ -1,18 +1,63 @@
use id3::TagLike;
use crate::types::{File, Tags};
use std::path::PathBuf;
pub fn extract_tags(filepath: PathBuf) -> Result<Tags, Box<dyn std::error::Error>> {
let file_extension = filepath.extension().unwrap();
if file_extension == "mp3" || file_extension == "flac" {
let tag = audiotags::Tag::new().read_from_path(filepath)?;
if file_extension == "mp3" {
let tag = id3::Tag::read_from_path(&filepath)?;
let mut contains_replaygain_tags = false;
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_2.0_specification#ID3v2
let frames = tag.frames();
for frame in frames {
if frame.id() == "TXXX" {
if let Some(extended_text) = frame.content().extended_text() {
if extended_text.value == "REPLAYGAIN_TRACK_GAIN" {
contains_replaygain_tags = true;
break;
}
}
}
}
return Ok(Tags {
title: String::from(tag.title().unwrap()),
artist: String::from(tag.artist().unwrap()),
supports_replaygain: true,
contains_replaygain_tags,
});
}
panic!("oops")
if file_extension == "flac" {
let tag = metaflac::Tag::read_from_path(filepath)?;
fn get_first(tag: &metaflac::Tag, key: &str) -> Option<String> {
if let Some(Some(v)) = tag.vorbis_comments().map(|c| c.get(key)) {
if !v.is_empty() {
Some(v[0].to_string())
} else {
None
}
} else {
None
}
}
let rpg = get_first(&tag, "REPLAYGAIN_TRACK_GAIN");
return Ok(Tags {
title: get_first(&tag, "TITLE").unwrap(),
artist: get_first(&tag, "ARTIST").unwrap(),
supports_replaygain: true,
contains_replaygain_tags: rpg.is_some(),
});
}
panic!("wrong extension")
}
pub fn extract_tags_from_file(file: File) -> Result<Tags, Box<dyn std::error::Error>> {

View file

@ -1,6 +1,7 @@
pub mod presets;
pub mod types;
pub mod progress_monitor;
#[allow(clippy::all)] mod transcoder;
#[allow(clippy::all)]
mod transcoder;
pub mod types;
pub(self) use self::progress_monitor::progress_monitor;
pub use self::transcoder::transcode;
pub(self) use self::progress_monitor::progress_monitor;

View file

@ -12,7 +12,6 @@ use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use serde::Deserialize;
use string_error::static_err;
#[derive(Debug, Clone, Deserialize)]
struct FFProbeOutput {
pub format: FFProbeFormat,
@ -132,4 +131,4 @@ pub fn progress_monitor(
});
Ok((file_path_string, child))
}
}

View file

@ -1,17 +1,10 @@
use std::{
fs,
process::Command,
sync::mpsc::{Sender},
thread::{JoinHandle},
};
use std::{fs, process::Command, sync::mpsc::Sender, thread::JoinHandle};
use crate::types::File;
use string_error::static_err;
use super::{progress_monitor, types::TranscodeConfig};
pub fn transcode(
file: File,
dest: String,