feat: add ffmpeg audio plugin and migrate verbosity to tracing levels
Release / check-release (push) Successful in 59s
Release / build_gnu (push) Skipped
Release / build_musl (push) Skipped
Release / build_windows (push) Skipped
Release / package_gnu (deb) (push) Skipped
Release / package_gnu (rpm) (push) Skipped
Release / package_musl (deb) (push) Skipped
Release / package_musl (rpm) (push) Skipped
Release / publish-release (push) Skipped
CI / Test (push) Successful in 1m44s
CI / Build Linux (push) Successful in 2m3s

This commit is contained in:
Elias Wendland
2026-07-15 22:57:00 +02:00
parent b97a108d1e
commit f58f73194e
7 changed files with 257 additions and 149 deletions
+1
View File
@@ -42,3 +42,4 @@ tempfile = "3.27.0"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread"] } tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = "0.3.23"
+7 -3
View File
@@ -77,7 +77,7 @@ fn main() {
println!("cargo:rerun-if-changed=plugins"); println!("cargo:rerun-if-changed=plugins");
// Add build metadata for versioning // 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"]) let build_time = run_command("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"])
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
@@ -93,9 +93,13 @@ fn main() {
.map(|s| s.split('\n').next().unwrap_or("").to_string()) .map(|s| s.split('\n').next().unwrap_or("").to_string())
.unwrap_or_else(|| "Unknown".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!( let version_string = format!(
"{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nprofile: {}\nrustc: {}", "{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nhost: {}\nprofile: {}\nrustc: {}\nauthors: {}\nrepository: {}",
version, build_time, git_commit, target, profile, rustc_version version, build_time, git_commit, target, host, profile, rustc_version, authors, repository
); );
let version_path = Path::new(&out_dir).join("version.txt"); let version_path = Path::new(&out_dir).join("version.txt");
+132
View File
@@ -0,0 +1,132 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// 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 <https://www.gnu.org/licenses/>.
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<Vec<u8>, 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())
}
}
+26 -41
View File
@@ -18,10 +18,15 @@ use std::path::PathBuf;
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt")); pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
#[derive(Parser, Debug)] #[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 { 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.")] #[arg(
pub verbose: u8, short = 'v',
long,
default_value = "warn",
help = "Output information about the conversion (trace, debug, info, warn, error)"
)]
pub verbose: Verbosity,
#[arg( #[arg(
short, short,
@@ -67,44 +72,24 @@ pub struct Args {
pub temp_dir: PathBuf, pub temp_dir: PathBuf,
} }
impl Args { #[derive(clap::ValueEnum, Clone, Debug, Copy, PartialEq, Eq)]
pub fn verbosity_level(&self) -> u8 { pub enum Verbosity {
self.verbose Trace,
Debug,
Info,
Warn,
Error,
}
impl From<Verbosity> 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);
}
+35 -14
View File
@@ -12,39 +12,56 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::path::Path; use std::path::Path;
pub fn identify_format(path: &str) -> Option<&'static str> { pub fn identify_format(path: &str, plugins: &[Box<dyn Plugin>]) -> Option<&'static str> {
let p = Path::new(path); let p = Path::new(path);
let ext = p let ext = p
.extension() .extension()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or(path) .unwrap_or(path)
.to_lowercase(); .to_lowercase();
match ext.as_str() { let ext_str = match ext.as_str() {
"jpg" | "jpeg" => Some("jpeg"), "jpg" => "jpeg",
"png" => Some("png"), other => other,
"mp4" => Some("mp4"), };
"webm" => Some("webm"),
"mkv" => Some("mkv"), for plugin in plugins {
"avi" => Some("avi"), if let Some(&f) = plugin.from_formats().iter().find(|&&f| f == ext_str) {
"mov" => Some("mov"), return Some(f);
"wmv" => Some("wmv"),
"flv" => Some("flv"),
"gif" => Some("gif"),
_ => None,
} }
if let Some(&f) = plugin.to_formats().iter().find(|&&f| f == ext_str) {
return Some(f);
}
}
None
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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<Vec<u8>, String> { Ok(vec![]) }
}
macro_rules! test_ident { macro_rules! test_ident {
($name:ident, $ext:expr, $expected:expr) => { ($name:ident, $ext:expr, $expected:expr) => {
#[test] #[test]
fn $name() { fn $name() {
assert_eq!(identify_format($ext), $expected); let mock = Box::new(MockPlugin);
let plugins: Vec<Box<dyn Plugin>> = 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_19, "test_file_without_extension", None);
test_ident!(test_identify_format_20, ".jpg", None); test_ident!(test_identify_format_20, ".jpg", None);
test_ident!(test_identify_format_21, "", 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"));
} }
+29 -47
View File
@@ -29,36 +29,40 @@ use std::process;
fn main() { fn main() {
let args = Args::parse(); 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 // Read input file
let input_bytes = match fs::read(&args.input_path) { let input_bytes = match fs::read(&args.input_path) {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
if !args.quiet { tracing::error!("Error reading input file: {}", e);
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.");
}
process::exit(1); process::exit(1);
} }
}; };
let plugins = get_plugins(); let plugins = get_plugins();
let to_format = match &args.output_path { let from_format = match identifier::identify_format(&args.input_path, &plugins) {
Some(out) => match identifier::identify_format(out) {
Some(f) => f, Some(f) => f,
None => { None => {
if !args.quiet { tracing::error!("Unknown input format.");
eprintln!("Error: Unknown output format."); process::exit(1);
} }
};
let to_format = match &args.output_path {
Some(out) => match identifier::identify_format(out, &plugins) {
Some(f) => f,
None => {
tracing::error!("Unknown output format.");
process::exit(1); process::exit(1);
} }
}, },
@@ -74,11 +78,7 @@ fn main() {
match default_target { match default_target {
Some(t) => t, Some(t) => t,
None => { None => {
if !args.quiet { tracing::error!("No output path provided, and no available conversions found.");
eprintln!(
"Error: No output path provided, and no available conversions found."
);
}
process::exit(1); process::exit(1);
} }
} }
@@ -88,59 +88,41 @@ fn main() {
let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority) { let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority) {
Some(p) => p, Some(p) => p,
None => { None => {
if !args.quiet { tracing::error!("No conversion path found from {} to {}.", from_format, to_format);
eprintln!(
"Error: No conversion path found from {} to {}.",
from_format, to_format
);
}
process::exit(1); process::exit(1);
} }
}; };
if args.test { if args.test {
if !args.quiet {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
println!("Test successful. Path: {}", path_str.join(" -> ")); tracing::info!("Test successful. Path: {}", path_str.join(" -> "));
}
return; return;
} }
let verbosity = args.verbosity_level(); let result = runner::run_conversion(&path, &input_bytes, &args.temp_dir);
let result = runner::run_conversion(&path, &input_bytes, verbosity, &args.temp_dir);
let output_bytes = match result { let output_bytes = match result {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
if !args.quiet { tracing::error!("Conversion error: {}", e);
eprintln!("Conversion error: {}", e);
}
process::exit(1); process::exit(1);
} }
}; };
if let Some(out_path) = &args.output_path { if let Some(out_path) = &args.output_path {
if let Err(e) = fs::write(out_path, &output_bytes) { if let Err(e) = fs::write(out_path, &output_bytes) {
if !args.quiet { tracing::error!("Error writing output file: {}", e);
eprintln!("Error writing output file: {}", e);
}
process::exit(1); process::exit(1);
} }
} else { } else {
let is_piped = !io::stdout().is_terminal(); let is_piped = !io::stdout().is_terminal();
if !is_piped && !args.write_to_console { if !is_piped && !args.write_to_console {
if !args.quiet { tracing::warn!("Outputting directly to console. Use -c to suppress this warning, or redirect to a file.");
eprintln!(
"Warning: Outputting directly to console. Use -c to suppress this warning, or redirect to a file."
);
}
} }
let mut stdout = io::stdout(); let mut stdout = io::stdout();
if let Err(e) = stdout.write_all(&output_bytes) { if let Err(e) = stdout.write_all(&output_bytes) {
if !args.quiet { tracing::error!("Error writing to console: {}", e);
eprintln!("Error writing to console: {}", e);
}
process::exit(1); process::exit(1);
} }
} }
+12 -29
View File
@@ -19,49 +19,40 @@ use std::time::Instant;
pub fn run_conversion( pub fn run_conversion(
path: &[(&dyn Plugin, &str, &str)], path: &[(&dyn Plugin, &str, &str)],
input: &[u8], input: &[u8],
verbosity: u8,
temp_dir: &Path, temp_dir: &Path,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
if verbosity >= 1 {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
eprintln!("Path taken: {}", path_str.join(" -> ")); tracing::info!("Path taken: {}", path_str.join(" -> "));
}
let mut current_data = input.to_vec(); let mut current_data = input.to_vec();
for (plugin, from_format, to_format) in path { for (plugin, from_format, to_format) in path {
if verbosity >= 2 { tracing::info!(
eprintln!(
"Converting {} to {} using plugin {}...", "Converting {} to {} using plugin {}...",
from_format, from_format,
to_format, to_format,
plugin.name() plugin.name()
); );
}
if verbosity >= 4 { tracing::debug!("[Plugin Log] Running plugin: {}", plugin.name());
eprintln!("[Plugin Log] Running plugin: {}", plugin.name()); tracing::debug!("[Plugin Log] Source format: {}", from_format);
eprintln!("[Plugin Log] Source format: {}", from_format); tracing::debug!("[Plugin Log] Target format: {}", to_format);
eprintln!("[Plugin Log] Target format: {}", to_format); tracing::debug!(
eprintln!(
"[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}", "[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}",
plugin.familiarity(*from_format, *to_format), plugin.familiarity(*from_format, *to_format),
plugin.quality(*from_format, *to_format), plugin.quality(*from_format, *to_format),
plugin.speed(*from_format, *to_format) plugin.speed(*from_format, *to_format)
); );
}
let start_time = Instant::now(); let start_time = Instant::now();
current_data = plugin.convert(&current_data, from_format, to_format, temp_dir)?; current_data = plugin.convert(&current_data, from_format, to_format, temp_dir)?;
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
if verbosity >= 3 { tracing::info!(
eprintln!( "Step '{} -> {}' completed in {:?}",
"Step '{} -> {}' completed in {:.2?}",
from_format, to_format, elapsed from_format, to_format, elapsed
); );
} }
}
Ok(current_data) Ok(current_data)
} }
@@ -114,7 +105,7 @@ mod tests {
} }
macro_rules! test_runner { 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] #[test]
fn $name() { fn $name() {
let plugin = MockPlugin { let plugin = MockPlugin {
@@ -128,7 +119,7 @@ mod tests {
let input = vec![0]; let input = vec![0];
let result = 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); assert_eq!(result.is_ok(), $expected_res);
if let Ok(res) = result { if let Ok(res) = result {
assert_eq!(res.len(), $expected_len); 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_0, false, true, 2);
test_runner!(test_runner_verb_1, 1, false, true, 2); test_runner!(test_runner_verb_0_fail, true, false, 0);
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);
} }