feat: implement robust format identification, plugin availability checks, and automatic conversion path rerouting on failure
Release / check-release (push) Successful in 16s
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 1m52s
CI / Build Linux (push) Successful in 2m0s

This commit is contained in:
Elias Wendland committed 2026-07-16 14:37:21 +02:00
1 parent a372937854
commit d3b7d7a18f
8 files changed
+244 -97

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ tracing = "0.1.44"
[dependencies] [dependencies]
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
image = "0.25.10" image = "0.25.10"
infer = "0.16.0"
petgraph = "0.8.3" petgraph = "0.8.3"
rust_ffmpeg = "1.0.0" rust_ffmpeg = "1.0.0"
tempfile = "3.27.0" tempfile = "3.27.0"
+61 -41
View File
@@ -23,41 +23,61 @@ impl Plugin for PluginImpl {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ffmpeg_audio" "ffmpeg_audio"
} }
fn from_formats(&self) -> Vec<&'static str> { fn from_formats(&self) -> Vec<&'static str> {
vec![ vec![
"mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "aiff", "au", "mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
] ]
} }
fn to_formats(&self) -> Vec<&'static str> { fn to_formats(&self) -> Vec<&'static str> {
vec![ vec![
"mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "aiff", "au", "mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
] ]
} }
fn is_available(&self) -> bool {
std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok()
}
fn familiarity(&self, _from: &str, to: &str) -> u8 { fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"mp3" | "wav" => 255, "mp3" | "wav" => 255,
"flac" | "m4a" | "aac" => 240, "m4a" | "aac" | "flac" => 240,
"ogg" | "opus" => 220, "ogg" | "opus" | "alac" => 220,
"wma" | "amr" => 150, "ac3" | "eac3" | "dts" => 180,
"aiff" | "au" => 120, "wma" | "amr" | "mka" | "mp2" => 150,
"aiff" | "au" | "caf" | "w64" => 120,
"ape" | "wv" | "tta" | "truehd" | "thd" => 100,
"spx" | "gsm" | "voc" | "ra" => 80,
_ => 128, _ => 128,
} }
} }
fn quality(&self, _from: &str, to: &str) -> u8 { fn quality(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"flac" | "wav" | "aiff" | "au" => 255, "flac" | "wav" | "aiff" | "au" | "alac" | "ape" | "wv" | "tta" | "caf" | "w64"
"opus" | "ogg" | "aac" | "m4a" => 220, | "truehd" | "thd" => 255,
"mp3" => 200, "opus" | "ogg" | "aac" | "m4a" | "eac3" | "dts" => 220,
"wma" | "amr" => 150, "mp3" | "ac3" | "mka" | "mp2" | "ra" => 200,
"wma" | "amr" | "spx" | "gsm" | "voc" => 150,
_ => 128, _ => 128,
} }
} }
fn speed(&self, _from: &str, to: &str) -> u8 { fn speed(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"wav" | "aiff" | "au" => 255, "wav" | "aiff" | "au" | "caf" | "w64" | "voc" => 255,
"flac" => 200, "flac" | "alac" | "wv" | "mp2" | "ac3" | "dts" => 200,
"mp3" | "aac" | "m4a" | "ogg" => 180, "mp3" | "aac" | "m4a" | "ogg" | "mka" | "eac3" => 180,
"opus" | "wma" | "amr" => 150, "opus" | "wma" | "amr" | "ape" | "tta" | "truehd" | "thd" | "spx" | "gsm" | "ra" => 150,
_ => 128, _ => 128,
} }
} }
@@ -85,33 +105,33 @@ impl Plugin for PluginImpl {
let mut raw_args = vec![]; let mut raw_args = vec![];
match to { match to {
"mp3" => { "mp3" => raw_args.extend(vec!["-c:a", "libmp3lame", "-q:a", "2"]),
raw_args.extend(vec!["-c:a", "libmp3lame", "-q:a", "2"]); "ogg" => raw_args.extend(vec!["-c:a", "libvorbis", "-q:a", "4"]),
} "aac" | "m4a" | "mka" => raw_args.extend(vec!["-c:a", "aac", "-b:a", "192k"]),
"ogg" => { "opus" => raw_args.extend(vec!["-c:a", "libopus", "-b:a", "128k"]),
raw_args.extend(vec!["-c:a", "libvorbis", "-q:a", "4"]); "mp2" => raw_args.extend(vec!["-c:a", "mp2", "-b:a", "192k"]),
} "wma" => raw_args.extend(vec!["-c:a", "wmav2", "-b:a", "192k"]),
"flac" => { "flac" => raw_args.extend(vec!["-c:a", "flac"]),
raw_args.extend(vec!["-c:a", "flac"]); "alac" => raw_args.extend(vec!["-c:a", "alac"]),
} "ape" => raw_args.extend(vec!["-c:a", "ape"]),
"aac" | "m4a" => { "wv" => raw_args.extend(vec!["-c:a", "wavpack"]),
raw_args.extend(vec!["-c:a", "aac", "-b:a", "192k"]); "tta" => raw_args.extend(vec!["-c:a", "tta"]),
} "ac3" => raw_args.extend(vec!["-c:a", "ac3", "-b:a", "384k"]),
"opus" => { "eac3" => raw_args.extend(vec!["-c:a", "eac3", "-b:a", "640k"]),
raw_args.extend(vec!["-c:a", "libopus", "-b:a", "128k"]); "dts" => raw_args.extend(vec!["-c:a", "dca", "-strict", "-2", "-b:a", "1536k"]),
} "truehd" | "thd" => raw_args.extend(vec!["-c:a", "truehd", "-strict", "-2"]),
"wma" => { "wav" | "aiff" | "au" | "caf" | "w64" => raw_args.extend(vec!["-c:a", "pcm_s16le"]),
raw_args.extend(vec!["-c:a", "wmav2", "-b:a", "192k"]); "amr" => raw_args.extend(vec![
} "-ar",
"amr" => { "8000",
raw_args.extend(vec!["-ar", "8000", "-c:a", "libopencore_amrnb", "-b:a", "12.2k"]); "-c:a",
} "libopencore_amrnb",
"wav" | "aiff" | "au" => { "-b:a",
raw_args.extend(vec!["-c:a", "pcm_s16le"]); "12.2k",
} ]),
_ => { "spx" => raw_args.extend(vec!["-c:a", "libspeex"]),
raw_args.extend(vec!["-c:a", "copy"]); "gsm" => raw_args.extend(vec!["-ar", "8000", "-c:a", "libgsm"]),
} _ => raw_args.extend(vec!["-c:a", "copy"]),
} }
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
+115 -21
View File
@@ -23,47 +23,63 @@ impl Plugin for PluginImpl {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ffmpeg_video" "ffmpeg_video"
} }
fn from_formats(&self) -> Vec<&'static str> { fn from_formats(&self) -> Vec<&'static str> {
vec!["mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif"] vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
} }
fn to_formats(&self) -> Vec<&'static str> { fn to_formats(&self) -> Vec<&'static str> {
vec!["mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif"] vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
} }
fn is_available(&self) -> bool {
std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok()
}
fn familiarity(&self, _from: &str, to: &str) -> u8 { fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"mp4" | "gif" => 255, "mp4" | "gif" => 255,
"mov" => 240, "mov" | "m4v" => 240,
"webm" => 220, "webm" => 220,
"mkv" => 180, "mkv" => 180,
"wmv" => 160, "wmv" | "mpg" | "mpeg" => 160,
"flv" => 120, "flv" | "avi" | "3gp" | "vob" => 120,
"avi" => 100, "ts" | "m2ts" | "mxf" | "ogv" | "apng" | "webp" => 100,
_ => 128, _ => 128,
} }
} }
fn quality(&self, _from: &str, to: &str) -> u8 { fn quality(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"mkv" => 250, "mkv" | "mxf" | "ts" | "m2ts" => 250,
"mov" => 250, "mov" | "mp4" | "m4v" => 240,
"mp4" => 240,
"webm" => 210, "webm" => 210,
"wmv" => 200, "wmv" | "mpg" | "mpeg" | "vob" => 200,
"flv" => 180, "flv" | "avi" | "ogv" | "3gp" => 150,
"avi" => 150, "gif" | "apng" => 100,
"gif" => 80, "webp" => 50,
_ => 200, _ => 200,
} }
} }
fn speed(&self, _from: &str, to: &str) -> u8 { fn speed(&self, _from: &str, to: &str) -> u8 {
match to { match to {
"mp4" => 220, "mp4" | "mov" | "m4v" => 220,
"mov" => 220, "mkv" | "mpg" | "mpeg" | "ts" | "m2ts" | "mxf" | "vob" => 200,
"mkv" => 200, "flv" | "avi" | "3gp" | "ogv" => 180,
"flv" => 190,
"avi" => 180,
"wmv" => 170, "wmv" => 170,
"gif" => 150, "gif" | "apng" => 150,
"webm" => 100, "webp" => 100,
"webm" => 50,
_ => 150, _ => 150,
} }
} }
@@ -91,7 +107,7 @@ impl Plugin for PluginImpl {
let mut raw_args = vec![]; let mut raw_args = vec![];
match to { match to {
"mp4" | "mkv" | "mov" => { "mp4" | "mkv" | "mov" | "m4v" => {
raw_args.extend(vec![ raw_args.extend(vec![
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p",
]); ]);
@@ -120,9 +136,87 @@ impl Plugin for PluginImpl {
"libmp3lame", "libmp3lame",
]); ]);
} }
"wmv" => {
raw_args.extend(vec![
"-c:v", "wmv2", "-b:v", "1024k", "-c:a", "wmav2", "-b:a", "128k",
]);
}
"flv" => {
raw_args.extend(vec![
"-c:v",
"flv1",
"-b:v",
"800k",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
]);
}
"ts" | "m2ts" | "mpg" | "mpeg" | "vob" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"mp2",
"-b:a",
"192k",
]);
}
"mxf" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"pcm_s16le",
]);
}
"ogv" => {
raw_args.extend(vec![
"-c:v",
"libtheora",
"-qscale:v",
"6",
"-c:a",
"libvorbis",
"-qscale:a",
"4",
]);
}
"3gp" => {
raw_args.extend(vec![
"-c:v",
"h263",
"-s",
"352x288",
"-c:a",
"libopencore_amrnb",
"-ar",
"8000",
]);
}
"gif" => { "gif" => {
raw_args.extend(vec!["-vf", "fps=15,scale=320:-1:flags=lanczos"]); raw_args.extend(vec!["-vf", "fps=15,scale=320:-1:flags=lanczos"]);
} }
"webp" => {
raw_args.extend(vec![
"-c:v",
"libwebp",
"-lossless",
"0",
"-qscale",
"75",
"-loop",
"0",
]);
}
"apng" => {
raw_args.extend(vec!["-plays", "0"]);
}
_ => { _ => {
raw_args.extend(vec!["-qscale:v", "3"]); raw_args.extend(vec!["-qscale:v", "3"]);
} }
+21 -6
View File
@@ -15,14 +15,29 @@
use crate::plugin::Plugin; use crate::plugin::Plugin;
use std::path::Path; use std::path::Path;
pub fn identify_format(path: &str, plugins: &[Box<dyn Plugin>]) -> Option<&'static str> { pub fn identify_format(path: &str, input: &[u8], plugins: &[Box<dyn Plugin>]) -> Option<&'static str> {
let mut ext_str_opt = None;
if let Some(kind) = infer::get(input) {
ext_str_opt = Some(kind.extension());
}
let p = Path::new(path); let p = Path::new(path);
let ext = p let mut ext = p
.extension() .extension()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or(path) .map(|s| s.to_lowercase());
.to_lowercase();
let ext_str = match ext.as_str() { if ext.is_none() {
ext = Some(path.to_lowercase());
}
let mut ext_str = ext.as_deref().unwrap_or("");
if ext_str_opt.is_some() {
ext_str = ext_str_opt.unwrap();
}
let ext_str = match ext_str {
"jpg" => "jpeg", "jpg" => "jpeg",
other => other, other => other,
}; };
@@ -61,7 +76,7 @@ mod tests {
fn $name() { fn $name() {
let mock = Box::new(MockPlugin); let mock = Box::new(MockPlugin);
let plugins: Vec<Box<dyn Plugin>> = vec![mock]; let plugins: Vec<Box<dyn Plugin>> = vec![mock];
assert_eq!(identify_format($ext, &plugins), $expected); assert_eq!(identify_format($ext, &[], &plugins), $expected);
} }
}; };
} }
+27 -22
View File
@@ -21,7 +21,7 @@ pub mod runner;
include!(concat!(env!("OUT_DIR"), "/plugins_gen.rs")); include!(concat!(env!("OUT_DIR"), "/plugins_gen.rs"));
use args::Args; use args::Args;
use clap::{Parser, CommandFactory, FromArgMatches}; use clap::{CommandFactory, FromArgMatches};
use std::fs; use std::fs;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use std::process; use std::process;
@@ -80,7 +80,7 @@ fn main() {
} }
}; };
let from_format = match identifier::identify_format(input_path, &plugins) { let from_format = match identifier::identify_format(input_path, &input_bytes, &plugins) {
Some(f) => f, Some(f) => f,
None => { None => {
tracing::error!("Unknown input format."); tracing::error!("Unknown input format.");
@@ -89,7 +89,7 @@ fn main() {
}; };
let to_format = match &args.output_path { let to_format = match &args.output_path {
Some(out) => match identifier::identify_format(out, &plugins) { Some(out) => match identifier::identify_format(out, &[], &plugins) {
Some(f) => f, Some(f) => f,
None => { None => {
tracing::error!("Unknown output format."); tracing::error!("Unknown output format.");
@@ -115,27 +115,32 @@ fn main() {
} }
}; };
let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority) { let mut banned_plugins: Vec<&str> = Vec::new();
Some(p) => p,
None => { let output_bytes = loop {
tracing::error!("No conversion path found from {} to {}.", from_format, to_format); let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority, &banned_plugins) {
process::exit(1); Some(p) => p,
None => {
tracing::error!("No conversion path found from {} to {}.", from_format, to_format);
process::exit(1);
}
};
if args.test {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
tracing::info!("Test successful. Path: {}", path_str.join(" -> "));
return;
} }
};
if args.test { match runner::run_conversion(&path, &input_bytes, &args.temp_dir) {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); Ok(output_bytes) => {
tracing::info!("Test successful. Path: {}", path_str.join(" -> ")); break output_bytes;
return; }
} Err((e, plugin_name)) => {
tracing::warn!("Conversion failed at plugin '{}': {}", plugin_name, e);
let result = runner::run_conversion(&path, &input_bytes, &args.temp_dir); tracing::warn!("Rerouting and trying alternative paths...");
banned_plugins.push(plugin_name);
let output_bytes = match result { }
Ok(b) => b,
Err(e) => {
tracing::error!("Conversion error: {}", e);
process::exit(1);
} }
}; };
+9 -5
View File
@@ -45,9 +45,13 @@ pub fn find_best_path<'a>(
from_format: &'a str, from_format: &'a str,
to_format: &str, to_format: &str,
priority: &str, priority: &str,
banned_plugins: &[&str],
) -> Option<Vec<(&'a dyn Plugin, &'a str, &'a str)>> { ) -> Option<Vec<(&'a dyn Plugin, &'a str, &'a str)>> {
let mut adj_list: HashMap<&str, Vec<&'a dyn Plugin>> = HashMap::new(); let mut adj_list: HashMap<&str, Vec<&'a dyn Plugin>> = HashMap::new();
for p in plugins { for p in plugins {
if banned_plugins.contains(&p.name()) || !p.is_available() {
continue;
}
for &from in &p.from_formats() { for &from in &p.from_formats() {
adj_list.entry(from).or_default().push(p.as_ref()); adj_list.entry(from).or_default().push(p.as_ref());
} }
@@ -243,7 +247,7 @@ mod tests {
}), }),
]; ];
let path = find_best_path(&plugins, "jpeg", "png", "fqs").unwrap(); let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
assert_eq!(path.len(), 1); assert_eq!(path.len(), 1);
assert_eq!(path[0].0.name(), "A"); assert_eq!(path[0].0.name(), "A");
} }
@@ -269,10 +273,10 @@ mod tests {
}), }),
]; ];
let path = find_best_path(&plugins, "jpeg", "png", "fqs").unwrap(); let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
assert_eq!(path[0].0.name(), "B"); assert_eq!(path[0].0.name(), "B");
let path = find_best_path(&plugins, "jpeg", "png", "qfs").unwrap(); let path = find_best_path(&plugins, "jpeg", "png", "qfs", &[]).unwrap();
assert_eq!(path[0].0.name(), "A"); assert_eq!(path[0].0.name(), "A");
} }
@@ -339,7 +343,7 @@ mod tests {
}), }),
]; ];
let path = find_best_path(&plugins, $from, $to, $priority); let path = find_best_path(&plugins, $from, $to, $priority, &[]);
if $expected_len == 0 { if $expected_len == 0 {
assert!(path.is_none()); assert!(path.is_none());
} else { } else {
@@ -408,7 +412,7 @@ mod tests {
s: 10, s: 10,
}), }),
]; ];
let path = find_best_path(&plugins, $from, $to, $priority); let path = find_best_path(&plugins, $from, $to, $priority, &[]);
if $expected_len == 0 { if $expected_len == 0 {
assert!(path.is_none()); assert!(path.is_none());
} else { } else {
+5
View File
@@ -19,6 +19,11 @@ pub trait Plugin: Send + Sync {
fn from_formats(&self) -> Vec<&'static str>; fn from_formats(&self) -> Vec<&'static str>;
fn to_formats(&self) -> Vec<&'static str>; fn to_formats(&self) -> Vec<&'static str>;
/// Checks if the plugin is available to run on this system (e.g. required binaries are installed).
fn is_available(&self) -> bool {
true
}
/// Score from 1 to 255. Higher is better. /// Score from 1 to 255. Higher is better.
fn familiarity(&self, from_format: &str, to_format: &str) -> u8; fn familiarity(&self, from_format: &str, to_format: &str) -> u8;
fn quality(&self, from_format: &str, to_format: &str) -> u8; fn quality(&self, from_format: &str, to_format: &str) -> u8;
+5 -2
View File
@@ -20,7 +20,7 @@ pub fn run_conversion(
path: &[(&dyn Plugin, &str, &str)], path: &[(&dyn Plugin, &str, &str)],
input: &[u8], input: &[u8],
temp_dir: &Path, temp_dir: &Path,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, (String, &'static str)> {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect(); let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
tracing::info!("Path taken: {}", path_str.join(" -> ")); tracing::info!("Path taken: {}", path_str.join(" -> "));
@@ -45,7 +45,10 @@ pub fn run_conversion(
); );
let start_time = Instant::now(); let start_time = Instant::now();
current_data = plugin.convert(&current_data, from_format, to_format, temp_dir)?; current_data = match plugin.convert(&current_data, from_format, to_format, temp_dir) {
Ok(data) => data,
Err(e) => return Err((e, plugin.name())),
};
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
tracing::info!( tracing::info!(