shrivel 0.4.0

Cross-platform FFmpeg orchestrator that transcodes videos to HEVC or AV1
//! Audio-only transcoding path.

use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::cli::AudioCodec;
use crate::conversion::{Outcome, quote};
use crate::progress::ProgressBars;
use serde::Deserialize;

#[derive(Deserialize)]
struct Probe {
    streams: Vec<Stream>,
    format: Option<Format>,
}
#[derive(Deserialize)]
struct Stream {
    bit_rate: Option<String>,
    duration: Option<String>,
}
#[derive(Deserialize)]
struct Format {
    bit_rate: Option<String>,
    duration: Option<String>,
}

pub(super) fn output_path(
    input: &Path,
    root: &Path,
    output: &Path,
    codec: AudioCodec,
) -> Option<PathBuf> {
    let mut path = output.join(input.strip_prefix(root).ok()?);
    path.set_extension(match codec {
        AudioCodec::Mp3 => "mp3",
        AudioCodec::Aac => "m4a",
        AudioCodec::Opus => "opus",
        AudioCodec::Copy => return None,
    });
    Some(path)
}

fn audio_bitrate(cq: u8, high: u16, low: u16) -> u16 {
    high - (((high - low) as u32 * cq.min(51) as u32 + 25) / 51) as u16
}

fn ffmpeg_args(
    input: &Path,
    output: &Path,
    codec: AudioCodec,
    cq: u8,
) -> Option<(Vec<OsString>, u16)> {
    let (encoder, bitrate) = match codec {
        AudioCodec::Mp3 => ("libmp3lame", audio_bitrate(cq, 320, 96)),
        AudioCodec::Aac => ("aac", audio_bitrate(cq, 256, 64)),
        AudioCodec::Opus => ("libopus", audio_bitrate(cq, 192, 48)),
        AudioCodec::Copy => return None,
    };
    let args = vec![
        "-hide_banner".into(),
        "-loglevel".into(),
        "error".into(),
        "-nostdin".into(),
        "-i".into(),
        input.as_os_str().to_owned(),
        "-map".into(),
        "0:a".into(),
        "-vn".into(),
        "-sn".into(),
        "-dn".into(),
        "-map_metadata".into(),
        "0".into(),
        "-c:a".into(),
        encoder.into(),
        "-b:a".into(),
        format!("{bitrate}k").into(),
        "-y".into(),
        output.as_os_str().to_owned(),
    ];
    Some((args, bitrate))
}

pub(super) fn convert_one(
    input: &Path,
    output: &Path,
    codec: AudioCodec,
    cq: u8,
    dynamic_cq: bool,
    dry_run: bool,
    skip_existing: bool,
    progress: &ProgressBars,
    label: &str,
) -> Outcome {
    let probe = Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-select_streams",
            "a",
            "-show_entries",
            "stream=codec_name,bit_rate,duration:format=bit_rate,duration",
            "-of",
            "json",
        ])
        .arg(input)
        .stdin(Stdio::null())
        .output();
    let raw = match probe.and_then(|o| {
        if o.status.success() {
            Ok(o.stdout)
        } else {
            Err(std::io::Error::other(
                String::from_utf8_lossy(&o.stderr).to_string(),
            ))
        }
    }) {
        Ok(bytes) => match serde_json::from_slice::<Probe>(&bytes) {
            Ok(p) if !p.streams.is_empty() => p,
            _ => {
                progress.println(format!("[ERR] {label}: no readable audio stream"));
                return Outcome::Failed;
            }
        },
        Err(e) => {
            progress.println(format!("[ERR] {label}: ffprobe failed: {e}"));
            return Outcome::Failed;
        }
    };
    if skip_existing && output.exists() {
        progress.println(format!("[SKIP] {label}: output exists"));
        return Outcome::Skipped;
    }
    if !dry_run && let Some(parent) = output.parent() {
        if let Err(e) = fs::create_dir_all(parent) {
            progress.println(format!(
                "[ERR] {label}: cannot create output directory: {e}"
            ));
            return Outcome::Failed;
        }
    }
    let stream = &raw.streams[0];
    let br = stream
        .bit_rate
        .as_deref()
        .or_else(|| raw.format.as_ref().and_then(|f| f.bit_rate.as_deref()))
        .and_then(|x| x.parse::<u32>().ok())
        .map(|x| x / 1000)
        .unwrap_or(0);
    let duration = stream
        .duration
        .as_deref()
        .or_else(|| raw.format.as_ref().and_then(|f| f.duration.as_deref()))
        .and_then(|x| x.parse::<f64>().ok())
        .filter(|x| x.is_finite() && *x > 0.0)
        .unwrap_or(0.0);
    let effective_cq = if dynamic_cq {
        crate::job::calculate_cq(cq, br)
    } else {
        cq
    };
    let (args, bps) =
        ffmpeg_args(input, output, codec, effective_cq).expect("audio codec required");
    if dry_run {
        progress.println(format!(
            "[DRY] ffmpeg {}",
            args.iter()
                .map(|a| quote(&a.to_string_lossy()))
                .collect::<Vec<_>>()
                .join(" ")
        ));
        let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
        let estimated = if duration > 0.0 {
            (bps as f64 * 1000.0 / 8.0 * duration) as u64
        } else {
            input_bytes
        };
        progress.println(format!(
            "[EST] {:.1} MB -> {:.1} MB",
            input_bytes as f64 / 1_048_576.0,
            estimated as f64 / 1_048_576.0
        ));
        return Outcome::Converted {
            input_bytes,
            output_bytes: estimated,
        };
    }
    let result = Command::new("ffmpeg")
        .args(&args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output();
    match result {
        Ok(out) if out.status.success() => {
            // Validate that ffprobe sees an output audio stream and a plausible duration.
            let valid = Command::new("ffprobe")
                .args([
                    "-v",
                    "error",
                    "-select_streams",
                    "a",
                    "-show_entries",
                    "stream=codec_name,duration:format=duration",
                    "-of",
                    "json",
                ])
                .arg(output)
                .output()
                .ok()
                .and_then(|o| serde_json::from_slice::<Probe>(&o.stdout).ok())
                .is_some_and(|p| {
                    !p.streams.is_empty() && {
                        let d = p.streams[0]
                            .duration
                            .as_deref()
                            .or_else(|| p.format.as_ref().and_then(|f| f.duration.as_deref()))
                            .and_then(|x| x.parse::<f64>().ok())
                            .unwrap_or(0.0);
                        duration <= 0.0 || d <= 0.0 || (duration - d).abs() <= 0.5
                    }
                });
            if !valid {
                let _ = fs::remove_file(output);
                progress.println(format!("[ERR] {label}: output validation failed"));
                return Outcome::Failed;
            }
            let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
            let output_bytes = fs::metadata(output).map(|m| m.len()).unwrap_or(0);
            progress.println(format!(
                "[OK] {label}: {:.1} MB -> {:.1} MB",
                input_bytes as f64 / 1_048_576.0,
                output_bytes as f64 / 1_048_576.0
            ));
            Outcome::Converted {
                input_bytes,
                output_bytes,
            }
        }
        Ok(out) => {
            let _ = fs::remove_file(output);
            progress.println(format!(
                "[ERR] {label}: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            ));
            Outcome::Failed
        }
        Err(e) => {
            progress.println(format!("[ERR] {label}: cannot run ffmpeg: {e}"));
            Outcome::Failed
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn output_extension_matches_selected_audio_codec() {
        let input = Path::new("source/nested/track.flac");
        let root = Path::new("source");
        let output = Path::new("converted");
        assert_eq!(
            output_path(input, root, output, AudioCodec::Mp3),
            Some(PathBuf::from("converted/nested/track.mp3"))
        );
        assert_eq!(
            output_path(input, root, output, AudioCodec::Aac),
            Some(PathBuf::from("converted/nested/track.m4a"))
        );
        assert_eq!(
            output_path(input, root, output, AudioCodec::Opus),
            Some(PathBuf::from("converted/nested/track.opus"))
        );
        assert_eq!(output_path(input, root, output, AudioCodec::Copy), None);
    }

    #[test]
    fn cq_maps_to_expected_bitrate_endpoints_and_direction() {
        assert_eq!(audio_bitrate(0, 192, 48), 192);
        assert_eq!(audio_bitrate(51, 192, 48), 48);
        assert!(audio_bitrate(40, 192, 48) < audio_bitrate(10, 192, 48));
    }

    #[test]
    fn ffmpeg_plan_maps_audio_only_and_selects_the_codec_encoder() {
        let (args, bitrate) = ffmpeg_args(
            Path::new("in.flac"),
            Path::new("out.opus"),
            AudioCodec::Opus,
            26,
        )
        .unwrap();
        let args: Vec<_> = args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(args.windows(2).any(|w| w == ["-map", "0:a"]));
        assert!(args.windows(2).any(|w| w == ["-c:a", "libopus"]));
        assert!(args.windows(2).any(|w| w == ["-vn", "-sn"]));
        assert!(
            args.windows(2)
                .any(|w| w == ["-b:a", &format!("{bitrate}k")])
        );
        assert!(!args.iter().any(|arg| arg == "-c:v"));
    }
}