diff --git a/Cargo.lock b/Cargo.lock index bd874ec..9da4e38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,7 +538,9 @@ dependencies = [ "clap", "crossbeam", "html-escape", + "id3", "lazy_static", + "metaflac", "notify", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f3adb6d..e2b372e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/args.rs b/src/args.rs index ea7f3e0..a6825df 100644 --- a/src/args.rs +++ b/src/args.rs @@ -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, } #[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, - #[clap(long, value_parser)] + #[clap(long)] pub transcode_config: Option, - #[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, - #[clap(long, value_parser)] + #[clap(long)] pub transcode_config: Option, - #[clap(long, value_parser)] + #[clap(long)] pub threads: Option, - #[clap(long, value_parser)] + #[clap(long)] pub no_skip_existing: bool, - #[clap(long, value_parser)] + #[clap(long)] pub single_directory: bool, } diff --git a/src/commands/copy.rs b/src/commands/copy.rs index d97430a..9bb93e8 100644 --- a/src/commands/copy.rs +++ b/src/commands/copy.rs @@ -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> { + 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> { 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, ©_args_arc, diff --git a/src/commands/genhtml.rs b/src/commands/genhtml.rs index 2c30c9f..7f72e71 100644 --- a/src/commands/genhtml.rs +++ b/src/commands/genhtml.rs @@ -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) }); diff --git a/src/commands/process.rs b/src/commands/process.rs index 5fa0841..a8b7309 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -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 = 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> { + 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>> = 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(()) } diff --git a/src/commands/transcode.rs b/src/commands/transcode.rs index c49b346..3115610 100644 --- a/src/commands/transcode.rs +++ b/src/commands/transcode.rs @@ -14,7 +14,6 @@ pub fn transcode_command( _args: &CLIArgs, transcode_args: &TranscodeCommandArgs, ) -> Result<(), Box> { - 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 ); } } diff --git a/src/types.rs b/src/types.rs index 7c83583..751a800 100644 --- a/src/types.rs +++ b/src/types.rs @@ -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, + 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, }, } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 6e37bd9..0bf548b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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(); diff --git a/src/utils/music_scanner.rs b/src/utils/music_scanner.rs index fdeae46..4822dc3 100644 --- a/src/utils/music_scanner.rs +++ b/src/utils/music_scanner.rs @@ -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, Box> { + let mut extra_files: Vec = 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, Box> { let mut files: Vec = Vec::new(); @@ -14,10 +38,11 @@ pub fn scan_for_music(src_dir: &String) -> Result, Box Result> { + 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::()?; + + // 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::()?; + 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> { + 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> { + 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> { + 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"); +} diff --git a/src/utils/tag_extractor.rs b/src/utils/tag_extractor.rs index 62622fb..6ccde1c 100644 --- a/src/utils/tag_extractor.rs +++ b/src/utils/tag_extractor.rs @@ -1,18 +1,63 @@ +use id3::TagLike; + use crate::types::{File, Tags}; use std::path::PathBuf; pub fn extract_tags(filepath: PathBuf) -> Result> { 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 { + 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> { diff --git a/src/utils/transcoder/mod.rs b/src/utils/transcoder/mod.rs index d6e58f7..33d4ac6 100644 --- a/src/utils/transcoder/mod.rs +++ b/src/utils/transcoder/mod.rs @@ -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; \ No newline at end of file diff --git a/src/utils/transcoder/progress_monitor.rs b/src/utils/transcoder/progress_monitor.rs index 8fa6919..b8e810d 100644 --- a/src/utils/transcoder/progress_monitor.rs +++ b/src/utils/transcoder/progress_monitor.rs @@ -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)) -} \ No newline at end of file +} diff --git a/src/utils/transcoder/transcoder.rs b/src/utils/transcoder/transcoder.rs index ab73d54..ac5d7a4 100644 --- a/src/utils/transcoder/transcoder.rs +++ b/src/utils/transcoder/transcoder.rs @@ -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,