move file format/tag handling to unified trait & handlers

This commit is contained in:
Chaos 2023-01-08 10:24:44 +00:00
parent e57ea88b22
commit 9952f20748
No known key found for this signature in database
17 changed files with 779 additions and 306 deletions

4
.gitignore vendored
View file

@ -3,7 +3,7 @@ build
result
.direnv
musicutil_old
# Added by cargo
temp
/target
/modules/taglib/target

28
Cargo.lock generated
View file

@ -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"

View file

@ -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"

102
flake.nix
View file

@ -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;
});
}

View file

@ -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(&copy_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 {

View file

@ -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<File>, 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();

View file

@ -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<String, Tags> = 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)
);
}
}

View file

@ -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<dyn std::error::Error>> {
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<dyn std::
return Ok(());
}
if file.tags.contains_replaygain_tags && !force {
if file.info.replaygain.is_some() && !force {
println!(
"Skipping replaygain for {:?}, contains already",
file.join_path_from_source()
@ -124,7 +123,10 @@ pub fn add_replaygain_tags(file: &File, force: bool) -> Result<(), Box<dyn std::
let replaygain_data = analyze_replaygain(file.join_path_to())?;
tag_replaygain(file.join_path_to(), replaygain_data)?;
let mut handler = get_format_handler(file)?;
handler.set_replaygain_data(replaygain_data)?;
handler.save_changes()?;
println!(
"Applied replaygain tags for {:?}",
@ -141,12 +143,13 @@ pub fn process_command(
println!("Scanning For Music");
let mut files = scan_for_music(&process_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)?;
}
println!("Renaming Files");

View file

@ -1,50 +1,9 @@
use std::path::PathBuf;
use id3::TagLike;
use crate::args::CLIArgs;
use crate::args::SetTagsCommandArgs;
use crate::types::File;
pub fn tag_mp3(
file: &File,
add_tags_args: &SetTagsCommandArgs,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())

View file

@ -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::<f64>().unwrap();
let track_peak = self.track_peak.parse::<f64>().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<ReplayGainData>,
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<File>,
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)
}

112
src/utils/formats/flac.rs Normal file
View file

@ -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<PathBuf>,
}
fn flac_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
}
}
impl AudioContainerFormat for FLACAudioFormat {
fn get_tags(&self, allow_missing: bool) -> Result<Tags, BoxedError> {
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<ReplayGainData> {
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<AudioFileInfo, BoxedError> {
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<FLACAudioFormat, BoxedError> {
Ok(FLACAudioFormat {
flac_tags: metaflac::Tag::read_from_path(path)?,
path: Box::new(path.clone()),
})
}

View file

@ -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<String>,
artist: Option<String>,
replaygain_data: Option<ReplayGainRawData>,
}
#[derive(Default)]
struct ExtractedData {
tags: Tags,
replaygain_data: Option<ReplayGainData>,
}
#[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<String>,
#[serde(default, alias = "REPLAYGAIN_TRACK_GAIN")]
pub replaygain_track_gain: Option<String>,
}
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<PathBuf>,
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<Tags, BoxedError> {
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<ReplayGainData> {
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<String> = 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<AudioFileInfo, BoxedError> {
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<GenericFFMpegAudioFormat, BoxedError> {
let mut handler = GenericFFMpegAudioFormat {
container_type,
path: Box::new(path.to_path_buf()),
extracted_data: ExtractedData::default(),
changes: Changes::default(),
};
handler.analyze()?;
Ok(handler)
}

149
src/utils/formats/id3.rs Normal file
View file

@ -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<PathBuf>,
}
impl AudioContainerFormat for ID3AudioFormat {
fn get_tags(&self, allow_missing: bool) -> Result<Tags, BoxedError> {
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<ReplayGainData> {
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<AudioFileInfo, BoxedError> {
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<ID3AudioFormat, BoxedError> {
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()),
})
}

76
src/utils/formats/mod.rs Normal file
View file

@ -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<dyn Error>;
#[derive(Copy, Clone, Debug)]
pub enum AudioContainer {
MP3,
FLAC,
WAV,
OGG,
AIFF,
Unknown,
}
pub trait AudioContainerFormat {
fn get_tags(&self, allow_missing: bool) -> Result<Tags, BoxedError>;
fn get_replaygain_data(&self) -> Option<ReplayGainData>;
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<AudioFileInfo, BoxedError>;
}
#[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<Box<dyn AudioContainerFormat>, 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")
}

View file

@ -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;

View file

@ -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<ReplayGainData, Box<dyn std::error::Error>> {
pub fn analyze_replaygain(path: PathBuf) -> Result<ReplayGainRawData, Box<dyn std::error::Error>> {
let output = Command::new(crate::meta::FFMPEG)
.args([
"-hide_banner",
@ -100,83 +95,8 @@ pub fn analyze_replaygain(path: PathBuf) -> Result<ReplayGainData, Box<dyn std::
break;
}
}
Ok(ReplayGainData {
Ok(ReplayGainRawData {
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,65 +0,0 @@
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" {
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<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>> {
extract_tags(file.join_path_to())
}