direct_play_nice 0.1.0-beta.3

CLI program that converts video files to direct-play-compatible formats.
Documentation
//! Configuration subsystem for resolving config sources and deserializing TOML into runtime settings.

use crate::gpu::HwAccel;
use crate::{
    AudioQuality, OcrEngine, OcrFormat, PrimaryVideoCriteria, SubMode, SubtitleFailurePolicy,
    UnsupportedVideoPolicy, VideoCodecPreference, VideoQuality,
};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

const DEFAULT_DIR_NAME: &str = "direct-play-nice";
const DEFAULT_FILE_NAME: &str = "config.toml";
pub const CONFIG_ENV_VAR: &str = "DIRECT_PLAY_NICE_CONFIG";

#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
    pub streaming_devices: Option<StreamingDevicesSetting>,
    pub video_quality: Option<VideoQuality>,
    pub video_codec: Option<VideoCodecPreference>,
    pub audio_quality: Option<AudioQuality>,
    pub max_video_bitrate: Option<String>,
    pub max_audio_bitrate: Option<String>,
    pub hw_accel: Option<HwAccel>,
    pub unsupported_video_policy: Option<UnsupportedVideoPolicy>,
    pub primary_video_stream_index: Option<usize>,
    pub primary_video_criteria: Option<PrimaryVideoCriteria>,
    pub servarr_output_extension: Option<String>,
    pub servarr_output_suffix: Option<String>,
    pub sub_mode: Option<SubMode>,
    pub subtitle_failure_policy: Option<SubtitleFailurePolicy>,
    pub ocr_default_language: Option<String>,
    pub ocr_engine: Option<OcrEngine>,
    pub ocr_format: Option<OcrFormat>,
    pub ocr_external_command: Option<String>,
    pub ocr_write_srt_sidecar: Option<bool>,
    pub skip_codec_check: Option<bool>,
    pub delete_source: Option<bool>,
    pub plex: Option<PlexSettings>,
}

#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct PlexSettings {
    pub refresh: Option<bool>,
    pub url: Option<String>,
    pub token: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StreamingDevicesSetting {
    Single(String),
    List(Vec<String>),
}

pub enum ConfigSource {
    Cli(PathBuf),
    Env(PathBuf),
    Default(PathBuf),
}

pub fn load(cli_path: Option<&Path>) -> Result<Option<(Config, ConfigSource)>> {
    if let Some(path) = cli_path {
        let cfg = read_from_path(path)?;
        return Ok(Some((cfg, ConfigSource::Cli(path.to_path_buf()))));
    }

    if let Some(env_path) = env::var_os(CONFIG_ENV_VAR) {
        let path = PathBuf::from(env_path);
        let cfg = read_from_path(&path)?;
        return Ok(Some((cfg, ConfigSource::Env(path))));
    }

    if let Some(path) = resolve_default_path() {
        if path.exists() {
            let cfg = read_from_path(&path)?;
            return Ok(Some((cfg, ConfigSource::Default(path))));
        }
    }

    Ok(None)
}

fn read_from_path(path: &Path) -> Result<Config> {
    if !path.exists() {
        return Err(anyhow!(
            "Configuration file '{}' does not exist.",
            path.display()
        ));
    }
    let contents =
        fs::read_to_string(path).with_context(|| format!("Reading config '{}'", path.display()))?;
    let cfg: Config = toml::from_str(&contents)
        .with_context(|| format!("Parsing config '{}'", path.display()))?;
    Ok(cfg)
}

fn resolve_default_path() -> Option<PathBuf> {
    if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") {
        let mut path = PathBuf::from(xdg);
        path.push(DEFAULT_DIR_NAME);
        path.push(DEFAULT_FILE_NAME);
        return Some(path);
    }

    if let Some(home) = env::var_os("HOME") {
        let mut path = PathBuf::from(home);
        path.push(".config");
        path.push(DEFAULT_DIR_NAME);
        path.push(DEFAULT_FILE_NAME);
        return Some(path);
    }

    None
}

#[cfg(test)]
mod tests {
    use crate::config::*;
    use std::io::Write;
    use std::sync::{Mutex, MutexGuard, OnceLock};
    use tempfile::NamedTempFile;

    fn env_lock() -> MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
    }

    #[test]
    fn load_from_cli_path() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(
            tmp,
            r#"
            [plex]
            refresh = true
            url = "http://localhost:32400"
            token = "abc123"
            "#
        )
        .unwrap();

        let path = tmp.path().to_path_buf();
        let (cfg, source) = load(Some(&path)).unwrap().unwrap();
        assert!(matches!(source, ConfigSource::Cli(_)));
        let plex = cfg.plex.unwrap();
        assert_eq!(plex.refresh, Some(true));
        assert_eq!(plex.url.as_deref(), Some("http://localhost:32400"));
        assert_eq!(plex.token.as_deref(), Some("abc123"));
    }

    #[test]
    fn load_from_env_path() {
        let _guard = env_lock();
        let mut tmp = NamedTempFile::new().unwrap();
        write!(
            tmp,
            r#"
            [plex]
            refresh = false
            token = "env-token"
            "#
        )
        .unwrap();

        let original_cfg = env::var_os(CONFIG_ENV_VAR);
        env::set_var(CONFIG_ENV_VAR, tmp.path());

        let (cfg, source) = load(None).unwrap().unwrap();
        assert!(matches!(source, ConfigSource::Env(_)));
        assert_eq!(cfg.plex.unwrap().token.as_deref(), Some("env-token"));

        match original_cfg {
            Some(val) => env::set_var(CONFIG_ENV_VAR, val),
            None => env::remove_var(CONFIG_ENV_VAR),
        }
    }

    #[test]
    fn absent_config_returns_none() {
        let _guard = env_lock();
        let original_cfg = env::var_os(CONFIG_ENV_VAR);
        let original_xdg = env::var_os("XDG_CONFIG_HOME");
        let original_home = env::var_os("HOME");

        env::remove_var(CONFIG_ENV_VAR);
        env::remove_var("XDG_CONFIG_HOME");
        env::remove_var("HOME");

        let loaded = load(None).unwrap();
        assert!(loaded.is_none());

        match original_cfg {
            Some(val) => env::set_var(CONFIG_ENV_VAR, val),
            None => env::remove_var(CONFIG_ENV_VAR),
        }
        match original_xdg {
            Some(val) => env::set_var("XDG_CONFIG_HOME", val),
            None => env::remove_var("XDG_CONFIG_HOME"),
        }
        match original_home {
            Some(val) => env::set_var("HOME", val),
            None => env::remove_var("HOME"),
        }
    }

    #[test]
    fn parses_subtitle_ocr_settings() {
        let mut tmp = NamedTempFile::new().unwrap();
        write!(
            tmp,
            r#"
            sub_mode = "force"
            ocr_default_language = "spa"
            ocr_engine = "external"
            ocr_format = "ass"
            ocr_external_command = "python3 /opt/ocr/run.py"
            ocr_write_srt_sidecar = true
            "#
        )
        .unwrap();

        let cfg = read_from_path(tmp.path()).unwrap();
        assert_eq!(cfg.sub_mode, Some(SubMode::Force));
        assert_eq!(cfg.ocr_default_language.as_deref(), Some("spa"));
        assert_eq!(cfg.ocr_engine, Some(OcrEngine::External));
        assert_eq!(cfg.ocr_format, Some(OcrFormat::Ass));
        assert_eq!(
            cfg.ocr_external_command.as_deref(),
            Some("python3 /opt/ocr/run.py")
        );
        assert_eq!(cfg.ocr_write_srt_sidecar, Some(true));
    }
}