From f58f73194e56a7836c2ecbdf323f1288a8af1aa6 Mon Sep 17 00:00:00 2001 From: Elias Wendland <193786789+eliaswen@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:57:00 +0200 Subject: [PATCH] feat: add ffmpeg audio plugin and migrate verbosity to tracing levels --- Cargo.toml | 3 +- build.rs | 10 ++- plugins/ffmpeg_audio.rs | 132 ++++++++++++++++++++++++++++++++++++++++ src/args.rs | 67 ++++++++------------ src/identifier.rs | 49 ++++++++++----- src/main.rs | 78 +++++++++--------------- src/runner.rs | 67 ++++++++------------ 7 files changed, 257 insertions(+), 149 deletions(-) create mode 100644 plugins/ffmpeg_audio.rs diff --git a/Cargo.toml b/Cargo.toml index c5392e2..e578ab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,4 +41,5 @@ rust_ffmpeg = "1.0.0" tempfile = "3.27.0" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread"] } -tracing = "0.1.44" \ No newline at end of file +tracing = "0.1.44" +tracing-subscriber = "0.3.23" diff --git a/build.rs b/build.rs index e558395..4036f62 100644 --- a/build.rs +++ b/build.rs @@ -77,7 +77,7 @@ fn main() { println!("cargo:rerun-if-changed=plugins"); // Add build metadata for versioning - let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string()); + let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "dev-unknown".to_string()); let build_time = run_command("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"]) .unwrap_or_else(|| "Unknown".to_string()); @@ -93,9 +93,13 @@ fn main() { .map(|s| s.split('\n').next().unwrap_or("").to_string()) .unwrap_or_else(|| "Unknown".to_string()); + let host = env::var("HOST").unwrap_or_else(|_| "Unknown".to_string()); + let authors = env::var("CARGO_PKG_AUTHORS").unwrap_or_else(|_| "Unknown".to_string()); + let repository = env::var("CARGO_PKG_REPOSITORY").unwrap_or_else(|_| "Unknown".to_string()); + let version_string = format!( - "{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nprofile: {}\nrustc: {}", - version, build_time, git_commit, target, profile, rustc_version + "{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nhost: {}\nprofile: {}\nrustc: {}\nauthors: {}\nrepository: {}", + version, build_time, git_commit, target, host, profile, rustc_version, authors, repository ); let version_path = Path::new(&out_dir).join("version.txt"); diff --git a/plugins/ffmpeg_audio.rs b/plugins/ffmpeg_audio.rs new file mode 100644 index 0000000..f2320e1 --- /dev/null +++ b/plugins/ffmpeg_audio.rs @@ -0,0 +1,132 @@ +// Copyright (C) 2026 Elias Wendland +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 exclusively. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use crate::plugin::Plugin; +use rust_ffmpeg::FFmpegBuilder; +use std::io::Write; +use std::path::Path; + +pub struct PluginImpl; + +impl Plugin for PluginImpl { + fn name(&self) -> &'static str { + "ffmpeg_audio" + } + fn from_formats(&self) -> Vec<&'static str> { + vec![ + "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "aiff", "au", + ] + } + fn to_formats(&self) -> Vec<&'static str> { + vec![ + "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "aiff", "au", + ] + } + fn familiarity(&self, _from: &str, to: &str) -> u8 { + match to { + "mp3" | "wav" => 255, + "flac" | "m4a" | "aac" => 240, + "ogg" | "opus" => 220, + "wma" | "amr" => 150, + "aiff" | "au" => 120, + _ => 128, + } + } + fn quality(&self, _from: &str, to: &str) -> u8 { + match to { + "flac" | "wav" | "aiff" | "au" => 255, + "opus" | "ogg" | "aac" | "m4a" => 220, + "mp3" => 200, + "wma" | "amr" => 150, + _ => 128, + } + } + fn speed(&self, _from: &str, to: &str) -> u8 { + match to { + "wav" | "aiff" | "au" => 255, + "flac" => 200, + "mp3" | "aac" | "m4a" | "ogg" => 180, + "opus" | "wma" | "amr" => 150, + _ => 128, + } + } + + fn convert( + &self, + input: &[u8], + _from: &str, + to: &str, + temp_dir: &Path, + ) -> Result, String> { + let mut temp_in = tempfile::Builder::new() + .suffix(&format!(".{}", _from)) + .tempfile_in(temp_dir) + .map_err(|e| e.to_string())?; + temp_in.write_all(input).map_err(|e| e.to_string())?; + + let temp_out = tempfile::Builder::new() + .suffix(&format!(".{}", to)) + .tempfile_in(temp_dir) + .map_err(|e| e.to_string())?; + let temp_out_path = temp_out.into_temp_path(); + + let in_path = temp_in.path().to_path_buf(); + + let mut raw_args = vec![]; + match to { + "mp3" => { + raw_args.extend(vec!["-c:a", "libmp3lame", "-q:a", "2"]); + } + "ogg" => { + raw_args.extend(vec!["-c:a", "libvorbis", "-q:a", "4"]); + } + "flac" => { + raw_args.extend(vec!["-c:a", "flac"]); + } + "aac" | "m4a" => { + raw_args.extend(vec!["-c:a", "aac", "-b:a", "192k"]); + } + "opus" => { + raw_args.extend(vec!["-c:a", "libopus", "-b:a", "128k"]); + } + "wma" => { + raw_args.extend(vec!["-c:a", "wmav2", "-b:a", "192k"]); + } + "amr" => { + raw_args.extend(vec!["-ar", "8000", "-c:a", "libopencore_amrnb", "-b:a", "12.2k"]); + } + "wav" | "aiff" | "au" => { + raw_args.extend(vec!["-c:a", "pcm_s16le"]); + } + _ => { + raw_args.extend(vec!["-c:a", "copy"]); + } + } + + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + rt.block_on(async { + FFmpegBuilder::new() + .map_err(|e| e.to_string())? + .input_path(in_path) + .output_path(temp_out_path.to_path_buf()) + .raw_args(raw_args) + .overwrite() + .run() + .await + .map_err(|e| e.to_string()) + })?; + + std::fs::read(&temp_out_path).map_err(|e| e.to_string()) + } +} diff --git a/src/args.rs b/src/args.rs index 19ec7db..08b9c96 100644 --- a/src/args.rs +++ b/src/args.rs @@ -18,10 +18,15 @@ use std::path::PathBuf; pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt")); #[derive(Parser, Debug)] -#[command(version = env!("CARGO_PKG_VERSION"), long_version = VERSION, about = "A file converter program.", long_about = None)] +#[command(version = VERSION, about = "A file converter program.", long_about = None)] pub struct Args { - #[arg(short = 'v', action = clap::ArgAction::Count, help = "Output information about the conversion. Use -v for path taken, -vv for step info, -vvv for timing, and -vvvv for plugin logs.")] - pub verbose: u8, + #[arg( + short = 'v', + long, + default_value = "warn", + help = "Output information about the conversion (trace, debug, info, warn, error)" + )] + pub verbose: Verbosity, #[arg( short, @@ -67,44 +72,24 @@ pub struct Args { pub temp_dir: PathBuf, } -impl Args { - pub fn verbosity_level(&self) -> u8 { - self.verbose +#[derive(clap::ValueEnum, Clone, Debug, Copy, PartialEq, Eq)] +pub enum Verbosity { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl From for tracing::Level { + fn from(v: Verbosity) -> Self { + match v { + Verbosity::Trace => tracing::Level::TRACE, + Verbosity::Debug => tracing::Level::DEBUG, + Verbosity::Info => tracing::Level::INFO, + Verbosity::Warn => tracing::Level::WARN, + Verbosity::Error => tracing::Level::ERROR, + } } } -#[cfg(test)] -mod tests { - use super::*; - - macro_rules! test_args_verbosity { - ($name:ident, $verbose:expr, $expected:expr) => { - #[test] - fn $name() { - let args = Args { - verbose: $verbose, - test: false, - quiet: false, - write_to_console: false, - priority: "fqs".to_string(), - input_path: "test".to_string(), - output_path: None, - temp_dir: std::path::PathBuf::from("/dev/shm"), - }; - assert_eq!(args.verbosity_level(), $expected); - } - }; - } - - test_args_verbosity!(test_verbosity_0, 0, 0); - test_args_verbosity!(test_verbosity_1, 1, 1); - test_args_verbosity!(test_verbosity_2, 2, 2); - test_args_verbosity!(test_verbosity_3, 3, 3); - test_args_verbosity!(test_verbosity_4, 4, 4); - test_args_verbosity!(test_verbosity_5, 5, 5); - test_args_verbosity!(test_verbosity_6, 10, 10); - test_args_verbosity!(test_verbosity_7, 100, 100); - test_args_verbosity!(test_verbosity_8, 255, 255); - test_args_verbosity!(test_verbosity_9, 128, 128); - test_args_verbosity!(test_verbosity_10, 50, 50); -} diff --git a/src/identifier.rs b/src/identifier.rs index 0b88f72..7fc1eab 100644 --- a/src/identifier.rs +++ b/src/identifier.rs @@ -12,39 +12,56 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use crate::plugin::Plugin; use std::path::Path; -pub fn identify_format(path: &str) -> Option<&'static str> { +pub fn identify_format(path: &str, plugins: &[Box]) -> Option<&'static str> { let p = Path::new(path); let ext = p .extension() .and_then(|s| s.to_str()) .unwrap_or(path) .to_lowercase(); - match ext.as_str() { - "jpg" | "jpeg" => Some("jpeg"), - "png" => Some("png"), - "mp4" => Some("mp4"), - "webm" => Some("webm"), - "mkv" => Some("mkv"), - "avi" => Some("avi"), - "mov" => Some("mov"), - "wmv" => Some("wmv"), - "flv" => Some("flv"), - "gif" => Some("gif"), - _ => None, + let ext_str = match ext.as_str() { + "jpg" => "jpeg", + other => other, + }; + + for plugin in plugins { + if let Some(&f) = plugin.from_formats().iter().find(|&&f| f == ext_str) { + return Some(f); + } + if let Some(&f) = plugin.to_formats().iter().find(|&&f| f == ext_str) { + return Some(f); + } } + None } #[cfg(test)] mod tests { use super::*; + struct MockPlugin; + impl Plugin for MockPlugin { + fn name(&self) -> &'static str { "mock" } + fn from_formats(&self) -> Vec<&'static str> { + vec!["jpeg", "png", "mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "au"] + } + fn to_formats(&self) -> Vec<&'static str> { vec![] } + fn familiarity(&self, _: &str, _: &str) -> u8 { 0 } + fn quality(&self, _: &str, _: &str) -> u8 { 0 } + fn speed(&self, _: &str, _: &str) -> u8 { 0 } + fn convert(&self, _: &[u8], _: &str, _: &str, _: &Path) -> Result, String> { Ok(vec![]) } + } + macro_rules! test_ident { ($name:ident, $ext:expr, $expected:expr) => { #[test] fn $name() { - assert_eq!(identify_format($ext), $expected); + let mock = Box::new(MockPlugin); + let plugins: Vec> = vec![mock]; + assert_eq!(identify_format($ext, &plugins), $expected); } }; } @@ -74,4 +91,8 @@ mod tests { test_ident!(test_identify_format_19, "test_file_without_extension", None); test_ident!(test_identify_format_20, ".jpg", None); test_ident!(test_identify_format_21, "", None); + test_ident!(test_identify_format_22, "test.mp3", Some("mp3")); + test_ident!(test_identify_format_23, "test.WAV", Some("wav")); + test_ident!(test_identify_format_24, "audio.flac", Some("flac")); + test_ident!(test_identify_format_25, "music.ogg", Some("ogg")); } diff --git a/src/main.rs b/src/main.rs index c4592f9..7a82e6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,36 +29,40 @@ use std::process; fn main() { let args = Args::parse(); + let level_filter = if args.quiet { + tracing_subscriber::filter::LevelFilter::OFF + } else { + tracing::Level::from(args.verbose).into() + }; + + tracing_subscriber::fmt() + .with_max_level(level_filter) + .init(); + // Read input file let input_bytes = match fs::read(&args.input_path) { Ok(b) => b, Err(e) => { - if !args.quiet { - eprintln!("Error reading input file: {}", e); - } - process::exit(1); - } - }; - - let from_format = match identifier::identify_format(&args.input_path) { - Some(f) => f, - None => { - if !args.quiet { - eprintln!("Error: Unknown input format."); - } + tracing::error!("Error reading input file: {}", e); process::exit(1); } }; let plugins = get_plugins(); + let from_format = match identifier::identify_format(&args.input_path, &plugins) { + Some(f) => f, + None => { + tracing::error!("Unknown input format."); + process::exit(1); + } + }; + let to_format = match &args.output_path { - Some(out) => match identifier::identify_format(out) { + Some(out) => match identifier::identify_format(out, &plugins) { Some(f) => f, None => { - if !args.quiet { - eprintln!("Error: Unknown output format."); - } + tracing::error!("Unknown output format."); process::exit(1); } }, @@ -74,11 +78,7 @@ fn main() { match default_target { Some(t) => t, None => { - if !args.quiet { - eprintln!( - "Error: No output path provided, and no available conversions found." - ); - } + tracing::error!("No output path provided, and no available conversions found."); process::exit(1); } } @@ -88,59 +88,41 @@ fn main() { let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority) { Some(p) => p, None => { - if !args.quiet { - eprintln!( - "Error: No conversion path found from {} to {}.", - from_format, to_format - ); - } + tracing::error!("No conversion path found from {} to {}.", from_format, to_format); process::exit(1); } }; if args.test { - if !args.quiet { - let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); - println!("Test successful. Path: {}", path_str.join(" -> ")); - } + let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); + tracing::info!("Test successful. Path: {}", path_str.join(" -> ")); return; } - let verbosity = args.verbosity_level(); - let result = runner::run_conversion(&path, &input_bytes, verbosity, &args.temp_dir); + let result = runner::run_conversion(&path, &input_bytes, &args.temp_dir); let output_bytes = match result { Ok(b) => b, Err(e) => { - if !args.quiet { - eprintln!("Conversion error: {}", e); - } + tracing::error!("Conversion error: {}", e); process::exit(1); } }; if let Some(out_path) = &args.output_path { if let Err(e) = fs::write(out_path, &output_bytes) { - if !args.quiet { - eprintln!("Error writing output file: {}", e); - } + tracing::error!("Error writing output file: {}", e); process::exit(1); } } else { let is_piped = !io::stdout().is_terminal(); if !is_piped && !args.write_to_console { - if !args.quiet { - eprintln!( - "Warning: Outputting directly to console. Use -c to suppress this warning, or redirect to a file." - ); - } + tracing::warn!("Outputting directly to console. Use -c to suppress this warning, or redirect to a file."); } let mut stdout = io::stdout(); if let Err(e) = stdout.write_all(&output_bytes) { - if !args.quiet { - eprintln!("Error writing to console: {}", e); - } + tracing::error!("Error writing to console: {}", e); process::exit(1); } } diff --git a/src/runner.rs b/src/runner.rs index 9f88a9f..10dac13 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -19,48 +19,39 @@ use std::time::Instant; pub fn run_conversion( path: &[(&dyn Plugin, &str, &str)], input: &[u8], - verbosity: u8, temp_dir: &Path, ) -> Result, String> { - if verbosity >= 1 { - let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); - eprintln!("Path taken: {}", path_str.join(" -> ")); - } + let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); + tracing::info!("Path taken: {}", path_str.join(" -> ")); let mut current_data = input.to_vec(); for (plugin, from_format, to_format) in path { - if verbosity >= 2 { - eprintln!( - "Converting {} to {} using plugin {}...", - from_format, - to_format, - plugin.name() - ); - } + tracing::info!( + "Converting {} to {} using plugin {}...", + from_format, + to_format, + plugin.name() + ); - if verbosity >= 4 { - eprintln!("[Plugin Log] Running plugin: {}", plugin.name()); - eprintln!("[Plugin Log] Source format: {}", from_format); - eprintln!("[Plugin Log] Target format: {}", to_format); - eprintln!( - "[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}", - plugin.familiarity(*from_format, *to_format), - plugin.quality(*from_format, *to_format), - plugin.speed(*from_format, *to_format) - ); - } + tracing::debug!("[Plugin Log] Running plugin: {}", plugin.name()); + tracing::debug!("[Plugin Log] Source format: {}", from_format); + tracing::debug!("[Plugin Log] Target format: {}", to_format); + tracing::debug!( + "[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}", + plugin.familiarity(*from_format, *to_format), + plugin.quality(*from_format, *to_format), + plugin.speed(*from_format, *to_format) + ); let start_time = Instant::now(); current_data = plugin.convert(¤t_data, from_format, to_format, temp_dir)?; let elapsed = start_time.elapsed(); - if verbosity >= 3 { - eprintln!( - "Step '{} -> {}' completed in {:.2?}", - from_format, to_format, elapsed - ); - } + tracing::info!( + "Step '{} -> {}' completed in {:?}", + from_format, to_format, elapsed + ); } Ok(current_data) @@ -114,7 +105,7 @@ mod tests { } macro_rules! test_runner { - ($name:ident, $verbosity:expr, $fail:expr, $expected_res:expr, $expected_len:expr) => { + ($name:ident, $fail:expr, $expected_res:expr, $expected_len:expr) => { #[test] fn $name() { let plugin = MockPlugin { @@ -128,7 +119,7 @@ mod tests { let input = vec![0]; let result = - run_conversion(&path, &input, $verbosity, std::path::Path::new("/dev/shm")); + run_conversion(&path, &input, std::path::Path::new("/dev/shm")); assert_eq!(result.is_ok(), $expected_res); if let Ok(res) = result { assert_eq!(res.len(), $expected_len); @@ -137,14 +128,6 @@ mod tests { }; } - test_runner!(test_runner_verb_0, 0, false, true, 2); - test_runner!(test_runner_verb_1, 1, false, true, 2); - test_runner!(test_runner_verb_2, 2, false, true, 2); - test_runner!(test_runner_verb_3, 3, false, true, 2); - test_runner!(test_runner_verb_4, 4, false, true, 2); - test_runner!(test_runner_verb_5, 5, false, true, 2); - test_runner!(test_runner_verb_0_fail, 0, true, false, 0); - test_runner!(test_runner_verb_1_fail, 1, true, false, 0); - test_runner!(test_runner_verb_4_fail, 4, true, false, 0); - test_runner!(test_runner_verb_255, 255, false, true, 2); + test_runner!(test_runner_verb_0, false, true, 2); + test_runner!(test_runner_verb_0_fail, true, false, 0); }