hvac-transcoder 5.4.0

GPU-accelerated media transcoder (HEVC/h265 via NVENC, VAAPI, VideoToolbox). Single-binary Tdarr alternative.
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

/// The default `config.yaml` baked in at compile time.
pub const EMBEDDED: &str = include_str!("../config.yaml");

#[derive(Debug, Clone)]
pub struct Config {
    pub target: TargetConfig,
    pub media_extensions: Vec<String>,
    pub output_dir: Option<PathBuf>,
    /// When true, skip disc images whose primary audio stream can't be
    /// chosen with confidence. The CLI `--skip-ambiguous-audio` flag
    /// `OR`s with this — turning either on enables skipping. See
    /// `probe::pick_primary_audio` for the ambiguity definition.
    pub skip_ambiguous_audio: bool,
}

#[derive(Debug, Clone)]
pub struct TargetConfig {
    #[allow(dead_code)]
    pub codec: String,
    pub quality: u32,
    pub preset: String,
    pub max_width: u32,
    pub max_height: u32,
    pub max_bitrate_kbps: u32,
    pub container: String,
    pub audio_codec: String,
    pub subtitle_codec: String,
}

impl Config {
    pub fn load(path: &Path) -> Result<Self> {
        let content = std::fs::read_to_string(path)?;
        parse_config(&content).with_context(|| format!("parsing {}", path.display()))
    }

    /// Parse the built-in default config embedded at compile time.
    /// Panics if the embedded YAML is invalid (a compile-time invariant).
    pub fn from_embedded() -> Self {
        parse_config(EMBEDDED).expect("embedded config.yaml is invalid")
    }
}

/// Minimal hand-rolled parser for the hvac config.yaml subset:
/// top-level scalar keys, a `target:` mapping, and a `media_extensions:` sequence.
/// Handles blank lines and `#`-prefixed comment lines; unknown keys are ignored.
fn parse_config(yaml: &str) -> Result<Config> {
    #[derive(PartialEq)]
    enum Section {
        None,
        Target,
        MediaExtensions,
    }

    let mut section = Section::None;
    let mut codec: Option<String> = None;
    let mut quality: u32 = 28;
    let mut preset = String::from("slow");
    let mut max_width: u32 = 3840;
    let mut max_height: u32 = 2160;
    let mut max_bitrate_kbps: u32 = 0;
    let mut container = String::from("mkv");
    let mut audio_codec = String::from("copy");
    let mut subtitle_codec = String::from("copy");
    let mut media_extensions: Vec<String> = Vec::new();
    let mut output_dir: Option<PathBuf> = None;
    let mut skip_ambiguous_audio = false;

    for (i, line) in yaml.lines().enumerate() {
        let lineno = i + 1;
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let indent = line.len() - line.trim_start().len();

        if indent == 0 {
            if trimmed == "target:" {
                section = Section::Target;
            } else if trimmed == "media_extensions:" {
                section = Section::MediaExtensions;
            } else if let Some(val) = trimmed.strip_prefix("output_dir:") {
                let val = val.trim();
                output_dir = if val.is_empty() {
                    None
                } else {
                    Some(PathBuf::from(val))
                };
                section = Section::None;
            } else if let Some(val) = trimmed.strip_prefix("skip_ambiguous_audio:") {
                skip_ambiguous_audio = parse_bool(val.trim()).with_context(|| {
                    format!("line {lineno}: skip_ambiguous_audio must be true or false")
                })?;
                section = Section::None;
            } else {
                section = Section::None;
            }
            continue;
        }

        match section {
            Section::Target => {
                if let Some((k, v)) = trimmed.split_once(':') {
                    let v = v.trim();
                    match k.trim() {
                        "codec" => codec = Some(v.to_string()),
                        "quality" => {
                            quality = v.parse().with_context(|| {
                                format!("line {lineno}: quality must be an integer")
                            })?
                        }
                        "preset" => preset = v.to_string(),
                        "max_width" => {
                            max_width = v.parse().with_context(|| {
                                format!("line {lineno}: max_width must be an integer")
                            })?
                        }
                        "max_height" => {
                            max_height = v.parse().with_context(|| {
                                format!("line {lineno}: max_height must be an integer")
                            })?
                        }
                        "max_bitrate_kbps" => {
                            max_bitrate_kbps = v.parse().with_context(|| {
                                format!("line {lineno}: max_bitrate_kbps must be an integer")
                            })?
                        }
                        "container" => container = v.to_string(),
                        "audio_codec" => audio_codec = v.to_string(),
                        "subtitle_codec" => subtitle_codec = v.to_string(),
                        _ => {}
                    }
                }
            }
            Section::MediaExtensions => {
                if let Some(ext) = trimmed.strip_prefix("- ") {
                    let ext = ext.trim();
                    if !ext.is_empty() {
                        media_extensions.push(ext.to_string());
                    }
                }
            }
            Section::None => {}
        }
    }

    let codec = codec
        .filter(|s| !s.is_empty())
        .ok_or_else(|| anyhow::anyhow!("missing required field: target.codec"))?;

    if media_extensions.is_empty() {
        anyhow::bail!(
            "missing required field: media_extensions (must list at least one extension)"
        );
    }

    Ok(Config {
        target: TargetConfig {
            codec,
            quality,
            preset,
            max_width,
            max_height,
            max_bitrate_kbps,
            container,
            audio_codec,
            subtitle_codec,
        },
        media_extensions,
        output_dir,
        skip_ambiguous_audio,
    })
}

/// Parse a YAML scalar bool. Accepts the usual lowercase forms; rejects
/// everything else so a typo (`yes`, `on`, `1`) doesn't silently coerce
/// to the wrong value. Lets us emit a helpful line-numbered error.
fn parse_bool(s: &str) -> Result<bool> {
    match s {
        "true" => Ok(true),
        "false" => Ok(false),
        other => anyhow::bail!("expected 'true' or 'false', got {:?}", other),
    }
}

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

    #[test]
    fn test_parse_config() {
        let yaml = r#"
target:
  codec: hevc
  quality: 22
  preset: slow
  max_width: 1920
  max_height: 1080
  max_bitrate_kbps: 0
  container: mkv
  audio_codec: copy
  subtitle_codec: copy
media_extensions:
  - mkv
  - mp4
"#;
        let config = parse_config(yaml).unwrap();
        assert_eq!(config.target.codec, "hevc");
        assert_eq!(config.target.quality, 22);
        assert_eq!(config.media_extensions.len(), 2);
    }

    #[test]
    fn test_embedded_const_is_valid_yaml() {
        let result = parse_config(EMBEDDED);
        assert!(
            result.is_ok(),
            "EMBEDDED config.yaml failed to parse: {:?}",
            result.err()
        );
    }

    #[test]
    fn test_from_embedded_has_expected_values() {
        let cfg = Config::from_embedded();
        assert_eq!(cfg.target.codec, "hevc");
        assert_eq!(cfg.target.quality, 28);
        assert_eq!(cfg.target.preset, "slow");
        assert_eq!(cfg.target.container, "mkv");
        assert_eq!(cfg.target.audio_codec, "copy");
        assert!(cfg.media_extensions.contains(&"mkv".to_string()));
        assert!(cfg.media_extensions.contains(&"mp4".to_string()));
        assert!(cfg.media_extensions.contains(&"iso".to_string()));
    }

    #[test]
    fn test_load_falls_back_gracefully_on_missing_file() {
        let result = Config::load(std::path::Path::new("/nonexistent/config.yaml"));
        assert!(result.is_err());
    }

    #[test]
    fn test_empty_media_extensions_is_an_error() {
        let yaml = r#"
target:
  codec: hevc
media_extensions:
"#;
        let result = parse_config(yaml);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("media_extensions"));
    }

    #[test]
    fn test_defaults() {
        let yaml = r#"
target:
  codec: hevc
media_extensions:
  - mkv
"#;
        let config = parse_config(yaml).unwrap();
        assert_eq!(config.target.quality, 28);
        assert_eq!(config.target.preset, "slow");
        assert_eq!(config.target.max_width, 3840);
        assert_eq!(config.target.container, "mkv");
    }

    #[test]
    fn test_blank_codec_value_is_an_error() {
        let yaml = r#"
target:
  codec:
media_extensions:
  - mkv
"#;
        let result = parse_config(yaml);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("target.codec"));
    }

    #[test]
    fn test_skip_ambiguous_audio_default_false() {
        let yaml = r#"
target:
  codec: hevc
media_extensions:
  - mkv
"#;
        let config = parse_config(yaml).unwrap();
        assert!(!config.skip_ambiguous_audio);
    }

    #[test]
    fn test_skip_ambiguous_audio_true() {
        let yaml = r#"
target:
  codec: hevc
media_extensions:
  - mkv
skip_ambiguous_audio: true
"#;
        let config = parse_config(yaml).unwrap();
        assert!(config.skip_ambiguous_audio);
    }

    #[test]
    fn test_skip_ambiguous_audio_false_is_explicit() {
        let yaml = r#"
target:
  codec: hevc
media_extensions:
  - mkv
skip_ambiguous_audio: false
"#;
        let config = parse_config(yaml).unwrap();
        assert!(!config.skip_ambiguous_audio);
    }

    #[test]
    fn test_skip_ambiguous_audio_typo_is_an_error() {
        // Reject ambiguous-bool spellings so a `yes` typo doesn't silently
        // disable skipping.
        let yaml = r#"
target:
  codec: hevc
media_extensions:
  - mkv
skip_ambiguous_audio: yes
"#;
        let result = parse_config(yaml);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("skip_ambiguous_audio"),
            "error should mention the field: {}",
            err
        );
    }

    #[test]
    fn test_unknown_keys_are_ignored() {
        let yaml = r#"
target:
  codec: hevc
  unknown_target_key: ignored
  future_option: 42
top_level_unknown: also_ignored
media_extensions:
  - mkv
"#;
        let config = parse_config(yaml).unwrap();
        assert_eq!(config.target.codec, "hevc");
        assert_eq!(config.media_extensions, vec!["mkv"]);
    }
}