use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
pub const SAFE_BITRATE_KBPS: u32 = 320;
pub const SAFE_SAMPLE_RATE: u32 = 44100;
#[derive(Debug, Clone)]
pub struct SourceInfo {
pub codec: String,
pub sample_rate: u32,
pub bitrate_kbps: Option<u32>,
}
impl SourceInfo {
pub fn is_lossy(&self) -> bool {
matches!(self.codec.as_str(), "mp3" | "aac")
}
pub fn is_compatible_mp3(&self) -> bool {
self.codec == "mp3"
&& self.sample_rate == SAFE_SAMPLE_RATE
&& self.bitrate_kbps == Some(SAFE_BITRATE_KBPS)
}
}
#[derive(Debug, Deserialize)]
struct ProbeStream {
codec_name: Option<String>,
sample_rate: Option<String>,
bit_rate: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ProbeFormat {
bit_rate: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ProbeOutput {
streams: Vec<ProbeStream>,
format: ProbeFormat,
}
pub fn probe(path: &Path) -> Result<SourceInfo> {
let output = Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
"-select_streams",
"a:0",
])
.arg(path)
.output()
.context("Failed to execute ffprobe. Is ffmpeg installed?")?;
if !output.status.success() {
bail!(
"ffprobe failed for {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let probe: ProbeOutput = serde_json::from_str(&stdout)
.with_context(|| format!("ffprobe returned no readable data for {}", path.display()))?;
let stream = probe
.streams
.first()
.ok_or_else(|| anyhow!("No audio stream in {}", path.display()))?;
Ok(SourceInfo {
codec: stream.codec_name.clone().unwrap_or_default(),
sample_rate: stream
.sample_rate
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
bitrate_kbps: stream
.bit_rate
.as_deref()
.or(probe.format.bit_rate.as_deref())
.and_then(|s| s.parse::<u32>().ok())
.map(|bps| (bps + 500) / 1000),
})
}
pub fn transcode(src: &Path, dst: &Path) -> Result<()> {
let attempts: [&[&str]; 2] = [
&["-af", "aresample=resampler=soxr:precision=28"],
&[],
];
let mut last_err = String::new();
for resample_args in attempts {
let mut cmd = Command::new("ffmpeg");
cmd.args(["-y", "-nostdin", "-i"])
.arg(src)
.args(["-map", "0:a:0", "-map", "0:v:0?"])
.args(["-c:a", "libmp3lame", "-b:a", "320k", "-ar", "44100"])
.args(resample_args)
.args(["-c:v", "mjpeg", "-q:v", "2"])
.args([
"-vf",
"scale='min(500,iw)':'min(500,ih)':force_original_aspect_ratio=decrease",
])
.args(["-disposition:v", "attached_pic"])
.args(["-map_metadata", "0", "-id3v2_version", "3", "-f", "mp3"])
.arg(dst);
let output = cmd
.output()
.context("Failed to execute ffmpeg. Is ffmpeg installed?")?;
if output.status.success() {
return Ok(());
}
let _ = std::fs::remove_file(dst);
let stderr = String::from_utf8_lossy(&output.stderr);
let lines: Vec<&str> = stderr.lines().collect();
last_err = lines[lines.len().saturating_sub(4)..].join("\n");
}
Err(anyhow!(
"ffmpeg transcode failed for {}:\n{}",
src.display(),
last_err
))
}