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"
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread"] }
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");
// 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");
+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"));
#[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<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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<dyn Plugin>]) -> 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<Vec<u8>, 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<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_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"));
}
+29 -47
View File
@@ -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 to_format = match &args.output_path {
Some(out) => match identifier::identify_format(out) {
let from_format = match identifier::identify_format(&args.input_path, &plugins) {
Some(f) => f,
None => {
if !args.quiet {
eprintln!("Error: Unknown output format.");
tracing::error!("Unknown input 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);
}
},
@@ -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(" -> "));
}
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);
}
}
+12 -29
View File
@@ -19,49 +19,40 @@ use std::time::Instant;
pub fn run_conversion(
path: &[(&dyn Plugin, &str, &str)],
input: &[u8],
verbosity: u8,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
if verbosity >= 1 {
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();
for (plugin, from_format, to_format) in path {
if verbosity >= 2 {
eprintln!(
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!(
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(&current_data, from_format, to_format, temp_dir)?;
let elapsed = start_time.elapsed();
if verbosity >= 3 {
eprintln!(
"Step '{} -> {}' completed in {:.2?}",
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);
}