From 9952f20748925f367f0eb373493bedf72c768ec5 Mon Sep 17 00:00:00 2001 From: Chaos Date: Sun, 8 Jan 2023 10:24:44 +0000 Subject: [PATCH] move file format/tag handling to unified trait & handlers --- .gitignore | 4 +- Cargo.lock | 28 +++- Cargo.toml | 2 +- flake.nix | 102 +++++++------ src/commands/copy.rs | 12 +- src/commands/genhtml.rs | 25 ++-- src/commands/get_tags.rs | 15 +- src/commands/process.rs | 25 ++-- src/commands/set_tags.rs | 60 ++------ src/types.rs | 82 ++++++++-- src/utils/formats/flac.rs | 112 ++++++++++++++ src/utils/formats/generic_ffmpeg.rs | 224 ++++++++++++++++++++++++++++ src/utils/formats/id3.rs | 149 ++++++++++++++++++ src/utils/formats/mod.rs | 76 ++++++++++ src/utils/mod.rs | 18 +-- src/utils/replaygain/mod.rs | 86 +---------- src/utils/tag_extractor.rs | 65 -------- 17 files changed, 779 insertions(+), 306 deletions(-) create mode 100644 src/utils/formats/flac.rs create mode 100644 src/utils/formats/generic_ffmpeg.rs create mode 100644 src/utils/formats/id3.rs create mode 100644 src/utils/formats/mod.rs delete mode 100644 src/utils/tag_extractor.rs diff --git a/.gitignore b/.gitignore index a951610..4a69fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ build result .direnv musicutil_old - -# Added by cargo +temp /target +/modules/taglib/target diff --git a/Cargo.lock b/Cargo.lock index 22c7341..5d75a1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" -[[package]] -name = "anyhow" -version = "1.0.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" - [[package]] name = "atty" version = "0.2.14" @@ -497,7 +491,6 @@ dependencies = [ name = "musicutil" version = "0.1.0" dependencies = [ - "anyhow", "clap", "crossbeam", "html-escape", @@ -511,6 +504,7 @@ dependencies = [ "serde_yaml", "string-error", "tempfile", + "thiserror", "walkdir", ] @@ -763,6 +757,26 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" +[[package]] +name = "thiserror" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index f8513e7..013a26e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,8 +29,8 @@ metaflac = "0.2.5" html-escape = "0.2.11" # error handling +thiserror = "1.0" string-error = "0.1.0" -anyhow = "1.0.66" # temporary file for transcode prefix file tempfile = "3" diff --git a/flake.nix b/flake.nix index 184cc4f..abbb43f 100644 --- a/flake.nix +++ b/flake.nix @@ -10,53 +10,71 @@ }; }; - outputs = { self, nixpkgs, utils, ... }: + outputs = { + self, + nixpkgs, + utils, + ... + }: { - overlay = final: prev: - let system = final.system; - in { - musicutil = final.rustPlatform.buildRustPackage rec { - pname = "musicutil"; - version = "latest"; - - src = ./.; - cargoLock = { lockFile = ./Cargo.lock; }; - - postPatch = '' - substituteInPlace src/meta.rs --replace 'ffmpeg' '${final.ffmpeg}/bin/ffmpeg' - substituteInPlace src/meta.rs --replace 'ffprobe' '${final.ffmpeg}/bin/ffprobe' - ''; - - doCheck = false; - nativeBuildInputs = with final.pkgs; [ pkg-config rustc cargo ]; - buildInputs = with final; [ ffmpeg ]; - }; - }; - } // utils.lib.eachSystem (utils.lib.defaultSystems) (system: - let - pkgs = import nixpkgs { - inherit system; - overlays = [ self.overlay ]; - }; + overlay = final: prev: let + system = final.system; in { - defaultPackage = self.packages."${system}".musicutil; - packages.musicutil = pkgs.musicutil; + musicutil = final.rustPlatform.buildRustPackage rec { + pname = "musicutil"; + version = "latest"; - apps = rec { - musicutil = { - type = "app"; - program = "${self.defaultPackage.${system}}/bin/musicutil"; - }; - default = musicutil; + src = ./.; + cargoLock = {lockFile = ./Cargo.lock;}; + + postPatch = '' + substituteInPlace src/meta.rs --replace 'ffmpeg' '${final.ffmpeg}/bin/ffmpeg' + substituteInPlace src/meta.rs --replace 'ffprobe' '${final.ffmpeg}/bin/ffprobe' + ''; + + doCheck = false; + nativeBuildInputs = with final.pkgs; [pkg-config rustc cargo]; + buildInputs = with final; [ffmpeg]; }; + }; + } + // utils.lib.eachSystem (utils.lib.defaultSystems) (system: let + pkgs = import nixpkgs { + inherit system; + overlays = [self.overlay]; + }; + in { + defaultPackage = self.packages."${system}".musicutil; + packages.musicutil = pkgs.musicutil; - defaultApp = self.apps."${system}".musicutil; - - devShell = pkgs.mkShell { - RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc; - buildInputs = with pkgs; [ rustc cargo clippy rust-analyzer rustfmt ]; + apps = rec { + musicutil = { + type = "app"; + program = "${self.defaultPackage.${system}}/bin/musicutil"; }; + default = musicutil; + }; - lib = pkgs.musicutil.lib; - }); + defaultApp = self.apps."${system}".musicutil; + + devShell = pkgs.mkShell { + RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + buildInputs = with pkgs; [taglib pkg-config rustc cargo clippy rust-analyzer rustfmt]; + shellHook = let + stdenv = pkgs.stdenv; + lib = pkgs.lib; + in '' + export BINDGEN_EXTRA_CLANG_ARGS="$(< ${stdenv.cc}/nix-support/libc-crt1-cflags) \ + $(< ${stdenv.cc}/nix-support/libc-cflags) \ + $(< ${stdenv.cc}/nix-support/cc-cflags) \ + $(< ${stdenv.cc}/nix-support/libcxx-cxxflags) \ + ${lib.optionalString stdenv.cc.isClang "-idirafter ${stdenv.cc.cc}/lib/clang/${lib.getVersion stdenv.cc.cc}/include"} \ + ${lib.optionalString stdenv.cc.isGNU "-isystem ${stdenv.cc.cc}/include/c++/${lib.getVersion stdenv.cc.cc} -isystem ${stdenv.cc.cc}/include/c++/${lib.getVersion stdenv.cc.cc}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/${lib.getVersion stdenv.cc.cc}/include"} \ + " + ''; + }; + + lib = pkgs.musicutil.lib; + }); } diff --git a/src/commands/copy.rs b/src/commands/copy.rs index 9bb93e8..e7d0e32 100644 --- a/src/commands/copy.rs +++ b/src/commands/copy.rs @@ -13,7 +13,8 @@ use crate::{ args::{CLIArgs, CopyCommandArgs}, types::File, utils::{ - extract_tags_from_file, scan_for_music, + formats::get_format_handler, + scan_for_music, transcoder::{ presets::{print_presets, transcode_preset_or_config}, transcode, @@ -40,12 +41,13 @@ pub fn copy_command( println!("Scanning For Music"); let mut files = scan_for_music(©_args.source)?; - println!("Extracting Tags"); + println!("Analysing Files"); for file in files.iter_mut() { - println!("Extracting tags from: {:?}", file.join_path_from_source()); + println!("Analysing: {:?}", file.join_path_from_source()); - let tags = extract_tags_from_file(file.to_owned())?; - file.tags = tags; + let handler = get_format_handler(file)?; + + file.info = handler.get_audio_file_info(true)?; } if copy_args.single_directory { diff --git a/src/commands/genhtml.rs b/src/commands/genhtml.rs index 7f72e71..a378111 100644 --- a/src/commands/genhtml.rs +++ b/src/commands/genhtml.rs @@ -1,7 +1,7 @@ use crate::args::CLIArgs; use crate::args::GenHTMLCommandArgs; use crate::types::File; -use crate::utils::extract_tags_from_file; +use crate::utils::formats::get_format_handler; use crate::utils::scan_for_music; use std::cmp::Ordering; use std::io::Write; @@ -41,8 +41,8 @@ fn table_for_files(files: Vec, includes_path: bool) -> String { false => "pure-table-even", }; - let data_title = encode_text(&file.tags.title); - let data_artist = encode_text(&file.tags.artist); + let data_title = encode_text(&file.info.tags.title); + let data_artist = encode_text(&file.info.tags.artist); let data_extension = encode_text(&file.extension); let mut path_data = String::new(); @@ -85,23 +85,28 @@ pub fn genhtml_command( println!("Scanning For Music"); let mut files = scan_for_music(&genhtml_args.source)?; - println!("Extracting Tags"); + println!("Analysing Files"); for file in files.iter_mut() { - println!("Extracting tags from: {:?}", file.join_path_from_source()); + println!("Analysing: {:?}", file.join_path_from_source()); - let tags = extract_tags_from_file(file.to_owned())?; - file.tags = tags; + let handler = get_format_handler(file)?; + + file.info = handler.get_audio_file_info(false)?; } files.sort_by(|a, b| -> Ordering { if a.path_from_source != b.path_from_source { return a.path_from_source.cmp(&b.path_from_source); } - if a.tags.title != b.tags.title { - return a.tags.title.cmp(&b.tags.title); + + let a_tags = &a.info.tags; + let b_tags = &b.info.tags; + + if a_tags.title != b_tags.title { + return a_tags.title.cmp(&b_tags.title); } - a.tags.artist.cmp(&b.tags.artist) + a_tags.artist.cmp(&b_tags.artist) }); let mut html_content = String::new(); diff --git a/src/commands/get_tags.rs b/src/commands/get_tags.rs index bd45503..5536313 100644 --- a/src/commands/get_tags.rs +++ b/src/commands/get_tags.rs @@ -6,7 +6,7 @@ use serde::Serialize; use crate::args::CLIArgs; use crate::args::GetTagsCommandArgs; use crate::types::File; -use crate::utils::extract_tags_from_file; +use crate::utils::formats::get_format_handler; #[derive(Debug, Clone, Serialize)] struct Tags { @@ -32,8 +32,9 @@ pub fn get_tags_command( } for file in files.iter_mut() { - let tags = extract_tags_from_file(file.clone())?; - file.tags = tags; + let handler = get_format_handler(file)?; + + file.info.tags = handler.get_tags(true)?; } if files.len() == 1 { @@ -42,17 +43,17 @@ pub fn get_tags_command( if get_tags_args.json { println!( "{}", - serde_json::to_string_pretty(&from_main_tags(&file.tags)).unwrap() + serde_json::to_string_pretty(&from_main_tags(&file.info.tags)).unwrap() ); } else { - println!("{:#?}", from_main_tags(&file.tags)); + println!("{:#?}", from_main_tags(&file.info.tags)); } } else if get_tags_args.json { let mut result: HashMap = HashMap::new(); for file in files.iter() { result.insert( file.join_path_to().to_string_lossy().to_string(), - from_main_tags(&file.tags), + from_main_tags(&file.info.tags), ); } println!("{}", serde_json::to_string_pretty(&result)?); @@ -61,7 +62,7 @@ pub fn get_tags_command( println!( "{}: {:#?}", file.join_path_to().to_string_lossy(), - from_main_tags(&file.tags) + from_main_tags(&file.info.tags) ); } } diff --git a/src/commands/process.rs b/src/commands/process.rs index a8b7309..a54f6fb 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -7,14 +7,13 @@ use crate::args::CLIArgs; use crate::args::ProcessCommandArgs; use crate::types::File; use crate::utils::ascii_reduce::reduce_to_ascii; -use crate::utils::extract_tags_from_file; +use crate::utils::formats::get_format_handler; 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; - let artist = &file.tags.artist; + let title = &file.info.tags.title; + let artist = &file.info.tags.artist; let replace_char = "_".to_string(); @@ -100,7 +99,7 @@ fn rename_file(_args: &CLIArgs, process_args: &ProcessCommandArgs, file: &mut Fi } pub fn add_replaygain_tags(file: &File, force: bool) -> Result<(), Box> { - if !file.tags.supports_replaygain { + if !file.info.supports_replaygain { println!( "Skipping replaygain for {:?}, not supported", file.join_path_from_source() @@ -109,7 +108,7 @@ pub fn add_replaygain_tags(file: &File, force: bool) -> Result<(), Box Result<(), Box Result<(), Box> { - let mut tag = id3::Tag::read_from_path(file.join_path_to())?; - - if let Some(title) = &add_tags_args.title { - tag.set_title(title); - } - - if let Some(artist) = &add_tags_args.artist { - tag.set_artist(artist); - } - - tag.write_to_path(file.join_path_to(), id3::Version::Id3v24)?; - - Ok(()) -} - -pub fn tag_flac( - file: &File, - add_tags_args: &SetTagsCommandArgs, -) -> Result<(), Box> { - let mut tag = metaflac::Tag::read_from_path(file.join_path_to())?; - - if let Some(title) = &add_tags_args.title { - tag.remove_vorbis("TITLE"); - tag.set_vorbis("TITLE", vec![title]); - } - - if let Some(artist) = &add_tags_args.artist { - tag.remove_vorbis("ARTIST"); - tag.set_vorbis("ARTIST", vec![artist]); - } - - tag.write_to_path(file.join_path_to())?; - - Ok(()) -} +use crate::utils::formats::get_format_handler; pub fn set_tags_command( _args: &CLIArgs, @@ -57,14 +16,17 @@ pub fn set_tags_command( } for file in files.iter() { - match file.extension.as_str() { - "mp3" => tag_mp3(file, add_tags_args)?, - "flac" => tag_flac(file, add_tags_args)?, - _ => panic!( - "Invalid File Extension for {}", - file.join_path_to().to_string_lossy() - ), + let mut handler = get_format_handler(file)?; + + if let Some(title) = &add_tags_args.title { + handler.set_title(title.clone())?; } + + if let Some(artist) = &add_tags_args.artist { + handler.set_artist(artist.clone())?; + } + + handler.save_changes()?; } Ok(()) diff --git a/src/types.rs b/src/types.rs index 6a1f7b9..0539e97 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,12 +1,75 @@ use std::path::PathBuf; +use crate::utils::formats::AudioContainer; + #[derive(Debug, Clone)] pub struct Tags { pub title: String, pub artist: String, +} +impl Default for Tags { + fn default() -> Self { + Tags { + title: "".to_string(), + artist: "".to_string(), + } + } +} + +#[derive(Debug, Clone)] + +pub struct ReplayGainData { + pub track_gain: String, + pub track_peak: String, +} + +impl ReplayGainData { + pub fn to_raw(&self) -> ReplayGainRawData { + let track_gain = self.track_gain.split(' ').next().unwrap_or("0.0"); + let track_gain = track_gain.parse::().unwrap(); + + let track_peak = self.track_peak.parse::().unwrap(); + + ReplayGainRawData { + track_gain, + track_peak, + } + } +} + +#[derive(Debug, Clone)] +pub struct ReplayGainRawData { + pub track_gain: f64, + pub track_peak: f64, +} + +impl ReplayGainRawData { + pub fn to_normal(&self) -> ReplayGainData { + ReplayGainData { + track_gain: format!("{:.2} dB", self.track_gain), + track_peak: format!("{:.6}", self.track_peak), + } + } +} + +#[derive(Debug, Clone)] +pub struct AudioFileInfo { + pub tags: Tags, + pub replaygain: Option, pub supports_replaygain: bool, - pub contains_replaygain_tags: bool, + pub container: AudioContainer, +} + +impl Default for AudioFileInfo { + fn default() -> Self { + AudioFileInfo { + tags: Tags::default(), + replaygain: None, + supports_replaygain: false, + container: AudioContainer::Unknown, + } + } } #[derive(Debug, Clone)] @@ -14,11 +77,14 @@ pub struct File { pub filename: String, pub extension: String, + // relative path to file's folder + pub path_to: PathBuf, + // path to folder from source + pub path_from_source: PathBuf, + pub extra_files: Vec, - pub path_to: PathBuf, - pub path_from_source: PathBuf, - pub tags: Tags, + pub info: AudioFileInfo, } impl File { @@ -52,14 +118,10 @@ impl File { 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, - }, + info: AudioFileInfo::default(), } } + pub fn join_filename(&self) -> String { format!("{}.{}", self.filename, self.extension) } diff --git a/src/utils/formats/flac.rs b/src/utils/formats/flac.rs new file mode 100644 index 0000000..fdf0547 --- /dev/null +++ b/src/utils/formats/flac.rs @@ -0,0 +1,112 @@ +use std::path::PathBuf; + +use crate::types::{AudioFileInfo, ReplayGainData, ReplayGainRawData, Tags}; + +use super::{AudioContainer, AudioContainerFormat, AudioFormatError, BoxedError}; + +pub struct FLACAudioFormat { + flac_tags: metaflac::Tag, + path: Box, +} + +fn flac_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 + } +} + +impl AudioContainerFormat for FLACAudioFormat { + fn get_tags(&self, allow_missing: bool) -> Result { + let title = flac_get_first(&self.flac_tags, "TITLE"); + let artist = flac_get_first(&self.flac_tags, "ARTIST"); + + if !allow_missing { + if title.is_none() { + return Err(Box::new(AudioFormatError::MissingTitle)); + } + if artist.is_none() { + return Err(Box::new(AudioFormatError::MissingArtist)); + } + } + + Ok(Tags { + title: title.unwrap(), + artist: artist.unwrap(), + }) + } + + fn get_replaygain_data(&self) -> Option { + let track_gain = flac_get_first(&self.flac_tags, "REPLAYGAIN_TRACK_GAIN"); + let track_peak = flac_get_first(&self.flac_tags, "REPLAYGAIN_TRACK_PEAK"); + + if track_gain.is_none() || track_peak.is_none() { + return None; + } + + Some(ReplayGainData { + track_gain: track_gain.unwrap(), + track_peak: track_peak.unwrap(), + }) + } + + fn supports_replaygain(&self) -> bool { + true + } + + fn set_title(&mut self, title: String) -> Result<(), BoxedError> { + self.flac_tags.remove_vorbis("TITLE"); + self.flac_tags.set_vorbis("TITLE", vec![title]); + + Ok(()) + } + + fn set_artist(&mut self, artist: String) -> Result<(), BoxedError> { + self.flac_tags.remove_vorbis("ARTIST"); + self.flac_tags.set_vorbis("ARTIST", vec![artist]); + + Ok(()) + } + + fn set_replaygain_data(&mut self, data: ReplayGainRawData) -> Result<(), BoxedError> { + self.flac_tags.remove_vorbis("REPLAYGAIN_TRACK_GAIN"); + self.flac_tags.remove_vorbis("REPLAYGAIN_TRACK_PEAK"); + + self.flac_tags.set_vorbis( + "REPLAYGAIN_TRACK_GAIN", + vec![format!("{:.2} dB", data.track_gain)], + ); + self.flac_tags.set_vorbis( + "REPLAYGAIN_TRACK_PEAK", + vec![format!("{:.6}", data.track_peak)], + ); + + Ok(()) + } + + fn save_changes(&mut self) -> Result<(), BoxedError> { + self.flac_tags.write_to_path(self.path.as_path())?; + Ok(()) + } + + fn get_audio_file_info(&self, allow_missing_tags: bool) -> Result { + return Ok(AudioFileInfo { + tags: self.get_tags(allow_missing_tags)?, + replaygain: self.get_replaygain_data(), + supports_replaygain: self.supports_replaygain(), + container: AudioContainer::FLAC, + }); + } +} + +pub fn new_flac_format_handler(path: &PathBuf) -> Result { + Ok(FLACAudioFormat { + flac_tags: metaflac::Tag::read_from_path(path)?, + path: Box::new(path.clone()), + }) +} diff --git a/src/utils/formats/generic_ffmpeg.rs b/src/utils/formats/generic_ffmpeg.rs new file mode 100644 index 0000000..3479e16 --- /dev/null +++ b/src/utils/formats/generic_ffmpeg.rs @@ -0,0 +1,224 @@ +use std::{path::{PathBuf, Path}, process::Command}; + +use serde::Deserialize; +use string_error::static_err; + +use crate::types::{AudioFileInfo, ReplayGainData, ReplayGainRawData, Tags}; + +use super::{AudioContainer, AudioContainerFormat, AudioFormatError, BoxedError}; + +#[derive(Default)] +struct Changes { + title: Option, + artist: Option, + replaygain_data: Option, +} + +#[derive(Default)] +struct ExtractedData { + tags: Tags, + replaygain_data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct FFProbeOutput { + pub format: FFProbeFormat, +} + +#[derive(Debug, Clone, Deserialize)] +struct FFProbeFormat { + pub tags: FFProbeTags, +} + +#[derive(Debug, Clone, Deserialize)] +struct FFProbeTags { + #[serde(alias = "TITLE")] + pub title: String, + #[serde(default, alias = "ARTIST")] + pub artist: String, + + #[serde(default, alias = "REPLAYGAIN_TRACK_PEAK")] + pub replaygain_track_peak: Option, + #[serde(default, alias = "REPLAYGAIN_TRACK_GAIN")] + pub replaygain_track_gain: Option, +} + +impl Default for FFProbeTags { + fn default() -> Self { + FFProbeTags { + title: "".to_string(), + artist: "".to_string(), + replaygain_track_peak: None, + replaygain_track_gain: None, + } + } +} + +pub struct GenericFFMpegAudioFormat { + container_type: AudioContainer, + path: Box, + + extracted_data: ExtractedData, + changes: Changes, +} + +impl GenericFFMpegAudioFormat { + fn analyze(&mut self) -> Result<(), BoxedError> { + let output = Command::new(crate::meta::FFPROBE) + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + &self.path.to_string_lossy(), + ]) + .output()?; + + if !output.status.success() { + print!("{:?}", String::from_utf8(output.stderr).unwrap()); + return Err(static_err("FFprobe Crashed")); + } + + let output_str = String::from_utf8(output.stdout).unwrap(); + let ffprobe_out: FFProbeOutput = serde_json::from_str(output_str.as_str())?; + + let tags = ffprobe_out.format.tags; + + self.extracted_data.tags = Tags { + title: tags.title, + artist: tags.artist, + }; + + if tags.replaygain_track_gain.is_some() && tags.replaygain_track_peak.is_some() { + self.extracted_data.replaygain_data = Some(ReplayGainData { + track_gain: tags.replaygain_track_gain.unwrap(), + track_peak: tags.replaygain_track_peak.unwrap(), + }); + } else { + self.extracted_data.replaygain_data = None; + } + + Ok(()) + } +} + +impl AudioContainerFormat for GenericFFMpegAudioFormat { + fn get_tags(&self, allow_missing: bool) -> Result { + let mut tags = self.extracted_data.tags.clone(); + + if let Some(title) = &self.changes.title { + tags.title = title.clone(); + } + + if let Some(artist) = &self.changes.title { + tags.artist = artist.clone(); + } + + if !allow_missing { + if tags.title.is_empty() { + return Err(Box::new(AudioFormatError::MissingTitle)); + } + if tags.artist.is_empty() { + return Err(Box::new(AudioFormatError::MissingArtist)); + } + } + + Ok(tags) + } + + fn get_replaygain_data(&self) -> Option { + if let Some(data) = &self.changes.replaygain_data { + return Some(data.to_normal()); + } + + self.extracted_data.replaygain_data.clone() + } + + fn supports_replaygain(&self) -> bool { + match self.container_type { + AudioContainer::MP3 => true, + AudioContainer::FLAC => true, + AudioContainer::WAV => true, + AudioContainer::OGG => false, // ffprobe can't do OGG tags + AudioContainer::AIFF => true, + AudioContainer::Unknown => false, + } + } + + fn set_title(&mut self, title: String) -> Result<(), BoxedError> { + self.changes.title = Some(title); + Ok(()) + } + + fn set_artist(&mut self, artist: String) -> Result<(), BoxedError> { + self.changes.artist = Some(artist); + Ok(()) + } + + fn set_replaygain_data(&mut self, data: ReplayGainRawData) -> Result<(), BoxedError> { + self.changes.replaygain_data = Some(data); + Ok(()) + } + + fn save_changes(&mut self) -> Result<(), BoxedError> { + let mut args: Vec = Vec::new(); + + let tempdir = tempfile::tempdir()?; + let temp_file = tempdir + .path() + .join(PathBuf::from(self.path.file_name().unwrap())); + + args.extend(vec![ + "-i".to_string(), + self.path.to_string_lossy().to_string(), + ]); + + args.extend(vec!["-c".to_string(), "copy".to_string()]); + + if let Some(title) = self.changes.title.clone() { + args.extend(vec![ + "-metadata".to_string(), + format!("title=\"{}\"", title), + ]) + } + + if let Some(artist) = self.changes.artist.clone() { + args.extend(vec!["-metadata".to_string(), format!("artist={}", artist)]) + } + + args.push(temp_file.to_string_lossy().to_string()); + + let output = Command::new(crate::meta::FFMPEG).args(args).output()?; + + println!("{:?}", String::from_utf8(output.stderr)); + + std::fs::copy(temp_file, self.path.to_path_buf())?; + + Ok(()) + } + + fn get_audio_file_info(&self, allow_missing_tags: bool) -> Result { + return Ok(AudioFileInfo { + tags: self.get_tags(allow_missing_tags)?, + replaygain: self.get_replaygain_data(), + supports_replaygain: self.supports_replaygain(), + container: self.container_type, + }); + } +} + +pub fn new_generic_ffmpeg_format_handler( + path: &Path, + container_type: AudioContainer, +) -> Result { + let mut handler = GenericFFMpegAudioFormat { + container_type, + path: Box::new(path.to_path_buf()), + extracted_data: ExtractedData::default(), + changes: Changes::default(), + }; + handler.analyze()?; + + Ok(handler) +} diff --git a/src/utils/formats/id3.rs b/src/utils/formats/id3.rs new file mode 100644 index 0000000..bf9ec42 --- /dev/null +++ b/src/utils/formats/id3.rs @@ -0,0 +1,149 @@ +use std::path::PathBuf; + +use id3::TagLike; + +use crate::types::{AudioFileInfo, ReplayGainData, ReplayGainRawData, Tags}; + +use super::{AudioContainer, AudioContainerFormat, AudioFormatError, BoxedError}; + +pub struct ID3AudioFormat { + container_type: AudioContainer, + id3_tags: id3::Tag, + path: Box, +} + +impl AudioContainerFormat for ID3AudioFormat { + fn get_tags(&self, allow_missing: bool) -> Result { + let title = self.id3_tags.title(); + let artist = self.id3_tags.artist(); + + if !allow_missing { + if title.is_none() { + return Err(Box::new(AudioFormatError::MissingTitle)); + } + if artist.is_none() { + return Err(Box::new(AudioFormatError::MissingArtist)); + } + } + + Ok(Tags { + title: String::from(title.unwrap()), + artist: String::from(artist.unwrap()), + }) + } + + fn get_replaygain_data(&self) -> Option { + let frames = self.id3_tags.frames(); + + let mut contains_replaygain_tags = false; + let mut replaygain_data = ReplayGainData { + track_gain: "".to_string(), + track_peak: "".to_string(), + }; + + for frame in frames { + if frame.id() == "TXXX" { + if let Some(extended_text) = frame.content().extended_text() { + match extended_text.description.as_str() { + "REPLAYGAIN_TRACK_GAIN" => { + contains_replaygain_tags = true; + replaygain_data.track_gain = extended_text.value.clone() + } + "REPLAYGAIN_TRACK_PEAK" => { + contains_replaygain_tags = true; + replaygain_data.track_peak = extended_text.value.clone() + } + _ => {} + } + } + } + } + + if !contains_replaygain_tags { + None + } else { + Some(replaygain_data) + } + } + + fn supports_replaygain(&self) -> bool { + true + } + + fn set_title(&mut self, title: String) -> Result<(), BoxedError> { + self.id3_tags.set_title(title); + + Ok(()) + } + + fn set_artist(&mut self, artist: String) -> Result<(), BoxedError> { + self.id3_tags.set_artist(artist); + + Ok(()) + } + + fn set_replaygain_data(&mut self, data: ReplayGainRawData) -> Result<(), BoxedError> { + let frames = self.id3_tags.remove("TXXX"); + + for frame in frames { + if let Some(extended_text) = frame.content().extended_text() { + if extended_text.description.starts_with("REPLAYGAIN") { + continue; + } + } + self.id3_tags.add_frame(frame); + } + + self.id3_tags.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), + }), + )); + + self.id3_tags.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), + }), + )); + + Ok(()) + } + + fn save_changes(&mut self) -> Result<(), BoxedError> { + self.id3_tags + .write_to_path(self.path.as_path(), id3::Version::Id3v24)?; + Ok(()) + } + + fn get_audio_file_info(&self, allow_missing_tags: bool) -> Result { + return Ok(AudioFileInfo { + tags: self.get_tags(allow_missing_tags)?, + replaygain: self.get_replaygain_data(), + supports_replaygain: self.supports_replaygain(), + container: self.container_type, + }); + } +} + +pub fn new_id3_format_handler( + path: &PathBuf, + container_type: AudioContainer, +) -> Result { + let id3_tags = match container_type { + // Only works on ID3 containing WAV files, but doesn't seem very widespread + AudioContainer::WAV => id3::Tag::read_from_wav_path(path)?, + AudioContainer::AIFF => id3::Tag::read_from_aiff_path(path)?, + + _ => id3::Tag::read_from_path(path)?, + }; + + Ok(ID3AudioFormat { + container_type, + id3_tags, + path: Box::new(path.clone()), + }) +} diff --git a/src/utils/formats/mod.rs b/src/utils/formats/mod.rs new file mode 100644 index 0000000..5fc6bbb --- /dev/null +++ b/src/utils/formats/mod.rs @@ -0,0 +1,76 @@ +pub mod flac; +pub mod generic_ffmpeg; +pub mod id3; + +use std::error::Error; +use std::path::Path; + +use thiserror::Error; + +use crate::types::{AudioFileInfo, File, ReplayGainData, ReplayGainRawData, Tags}; + +use self::flac::new_flac_format_handler; +use self::id3::new_id3_format_handler; + +type BoxedError = Box; + +#[derive(Copy, Clone, Debug)] +pub enum AudioContainer { + MP3, + FLAC, + WAV, + OGG, + AIFF, + Unknown, +} + +pub trait AudioContainerFormat { + fn get_tags(&self, allow_missing: bool) -> Result; + fn get_replaygain_data(&self) -> Option; + fn supports_replaygain(&self) -> bool; + + fn set_title(&mut self, title: String) -> Result<(), BoxedError>; + fn set_artist(&mut self, artist: String) -> Result<(), BoxedError>; + fn set_replaygain_data(&mut self, data: ReplayGainRawData) -> Result<(), BoxedError>; + + fn save_changes(&mut self) -> Result<(), BoxedError>; + + fn get_audio_file_info(&self, allow_missing_tags: bool) -> Result; +} + +#[derive(Error, Debug)] +pub enum AudioFormatError { + #[error("title missing from tags")] + MissingTitle, + #[error("artist missing from tags")] + MissingArtist, +} + +pub fn get_format_handler(file: &File) -> Result, BoxedError> { + // TODO: detect format from magic bytes rather than extension + match file.extension.as_str() { + "mp3" => { + return Ok(Box::new(new_id3_format_handler( + &file.join_path_to(), + AudioContainer::MP3, + )?)) + } + // "aiff" => return Ok(Box::new(new_generic_ffmpeg_format_handler(&file.join_path_to(), AudioContainer::AIFF)?)), + // "wav" => return Ok(Box::new(new_generic_ffmpeg_format_handler(&file.join_path_to(), AudioContainer::WAV)?)), + "flac" => return Ok(Box::new(new_flac_format_handler(&file.join_path_to())?)), + _ => { + panic!("unsupported filetype"); + } + } +} + +pub fn is_supported_file_extension(file_path: &Path) -> bool { + let ext = file_path.extension(); + if ext.is_none() { + return false; + } + + let ext = ext.unwrap().to_str().unwrap(); + + matches!(ext, "mp3" | "flac") +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index fb69cb4..91e3a02 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,20 +1,10 @@ -use std::path::Path; - pub mod ascii_reduce; -pub(self) mod music_scanner; pub mod replaygain; -pub(self) mod tag_extractor; pub mod transcoder; +pub mod formats; +pub(self) mod music_scanner; + 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(); - if ext.is_none() { - return false; - } - let ext = ext.unwrap().to_str().unwrap(); - - matches!(ext, "mp3" | "flac") -} +pub use formats::is_supported_file_extension; diff --git a/src/utils/replaygain/mod.rs b/src/utils/replaygain/mod.rs index 22e3855..3da1f84 100644 --- a/src/utils/replaygain/mod.rs +++ b/src/utils/replaygain/mod.rs @@ -4,16 +4,11 @@ use std::{ process::Command, }; -use id3::TagLike; use string_error::static_err; -#[derive(Debug, Clone)] -pub struct ReplayGainData { - pub track_gain: f64, - pub track_peak: f64, -} +use crate::types::ReplayGainRawData; -pub fn analyze_replaygain(path: PathBuf) -> Result> { +pub fn analyze_replaygain(path: PathBuf) -> Result> { let output = Command::new(crate::meta::FFMPEG) .args([ "-hide_banner", @@ -100,83 +95,8 @@ pub fn analyze_replaygain(path: PathBuf) -> Result 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 deleted file mode 100644 index 6ccde1c..0000000 --- a/src/utils/tag_extractor.rs +++ /dev/null @@ -1,65 +0,0 @@ -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" { - 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, - }); - } - - 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> { - extract_tags(file.join_path_to()) -}