diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index fe50d8e..9a79233 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -10,6 +10,16 @@ publish = false name = "arr" path = "src/main.rs" +# Forwards straight to `arr-subs`' own features (DESIGN.md §15): which +# translators a build ships is a compile-time choice, off by default, same as +# the crate that implements them. +[features] +default = [] +translate-openai = ["arr-subs/translate-openai"] +translate-deepl = ["arr-subs/translate-deepl"] +translate-google = ["arr-subs/translate-google"] +translate-command = ["arr-subs/translate-command"] + [dependencies] arr-api = { workspace = true } arr-compat = { workspace = true } diff --git a/crates/arr-daemon/src/config.rs b/crates/arr-daemon/src/config.rs index 377d98e..5bc840d 100644 --- a/crates/arr-daemon/src/config.rs +++ b/crates/arr-daemon/src/config.rs @@ -40,6 +40,7 @@ pub const ENV_PODNAPISI_USERNAME: &str = "ARR_PODNAPISI_USERNAME"; pub const ENV_PODNAPISI_PASSWORD: &str = "ARR_PODNAPISI_PASSWORD"; pub const ENV_TRANSLATE_OPENAI_API_KEY: &str = "ARR_TRANSLATE_OPENAI_API_KEY"; pub const ENV_TRANSLATE_OPENAI_BASE_URL: &str = "ARR_TRANSLATE_OPENAI_BASE_URL"; +pub const ENV_TRANSLATE_OPENAI_MODEL: &str = "ARR_TRANSLATE_OPENAI_MODEL"; pub const ENV_TRANSLATE_DEEPL_API_KEY: &str = "ARR_TRANSLATE_DEEPL_API_KEY"; pub const ENV_TRANSLATE_DEEPL_BASE_URL: &str = "ARR_TRANSLATE_DEEPL_BASE_URL"; pub const ENV_TRANSLATE_GOOGLE_API_KEY: &str = "ARR_TRANSLATE_GOOGLE_API_KEY"; @@ -64,6 +65,10 @@ pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096"; pub const DEFAULT_NTFY_URL: &str = "http://localhost"; pub const DEFAULT_ALASS_PATH: &str = "alass"; pub const DEFAULT_FFMPEG_PATH: &str = "ffmpeg"; +/// #191 made the model an `OpenAi::new` argument rather than an +/// `OpenAiConfig` field; no other bootstrap key covers it, so this is that +/// key's default. +pub const DEFAULT_TRANSLATE_OPENAI_MODEL: &str = "gpt-4o-mini"; #[derive(Debug, thiserror::Error)] pub enum ConfigError { @@ -114,6 +119,8 @@ struct ConfigFile { #[serde(default)] translate_openai_base_url: Option, #[serde(default)] + translate_openai_model: Option, + #[serde(default)] translate_deepl_base_url: Option, #[serde(default)] translate_google_base_url: Option, @@ -169,6 +176,7 @@ pub struct EnvOverrides { pub podnapisi_password: Option, pub translate_openai_api_key: Option, pub translate_openai_base_url: Option, + pub translate_openai_model: Option, pub translate_deepl_api_key: Option, pub translate_deepl_base_url: Option, pub translate_google_api_key: Option, @@ -204,6 +212,7 @@ impl EnvOverrides { podnapisi_password: std::env::var(ENV_PODNAPISI_PASSWORD).ok(), translate_openai_api_key: std::env::var(ENV_TRANSLATE_OPENAI_API_KEY).ok(), translate_openai_base_url: std::env::var(ENV_TRANSLATE_OPENAI_BASE_URL).ok(), + translate_openai_model: std::env::var(ENV_TRANSLATE_OPENAI_MODEL).ok(), translate_deepl_api_key: std::env::var(ENV_TRANSLATE_DEEPL_API_KEY).ok(), translate_deepl_base_url: std::env::var(ENV_TRANSLATE_DEEPL_BASE_URL).ok(), translate_google_api_key: std::env::var(ENV_TRANSLATE_GOOGLE_API_KEY).ok(), @@ -252,6 +261,9 @@ pub struct Config { /// §15. No default: an OpenAI-compatible endpoint has no universal /// address the way `DeepL` or Google Translate do. pub translate_openai_base_url: Option, + /// §15. Unlike the base URL, a model does have a usable default — + /// see [`DEFAULT_TRANSLATE_OPENAI_MODEL`]. + pub translate_openai_model: String, pub translate_deepl_api_key: Option, /// `None` means the backend's own built-in default when it lands (#192). pub translate_deepl_base_url: Option, @@ -268,6 +280,75 @@ pub struct Config { pub ffmpeg_path: PathBuf, } +/// §15/§10 subtitle bootstrap: provider and translator credentials, base +/// URLs, the remote-command template, and binary paths. Resolved separately +/// from [`Config::resolve`] for the same reason `arr-daemon::api_state` is +/// split out of `run` — one field per provider or backend, and `resolve` is +/// already at the too-many-lines limit. +struct SubtitleBootstrap { + opensubtitles_api_key: Option, + opensubtitles_username: Option, + opensubtitles_password: Option, + podnapisi_username: Option, + podnapisi_password: Option, + translate_openai_api_key: Option, + translate_openai_base_url: Option, + translate_openai_model: String, + translate_deepl_api_key: Option, + translate_deepl_base_url: Option, + translate_google_api_key: Option, + translate_google_base_url: Option, + translate_command_template: Option, + alass_path: PathBuf, + ffmpeg_path: PathBuf, +} + +fn resolve_subtitle_bootstrap(env: &EnvOverrides, file: &ConfigFile) -> SubtitleBootstrap { + SubtitleBootstrap { + opensubtitles_api_key: env.opensubtitles_api_key.clone(), + opensubtitles_username: env.opensubtitles_username.clone(), + opensubtitles_password: env.opensubtitles_password.clone(), + podnapisi_username: env.podnapisi_username.clone(), + podnapisi_password: env.podnapisi_password.clone(), + translate_openai_api_key: env.translate_openai_api_key.clone(), + translate_openai_base_url: env + .translate_openai_base_url + .clone() + .or_else(|| file.translate_openai_base_url.clone()), + translate_openai_model: env + .translate_openai_model + .clone() + .or_else(|| file.translate_openai_model.clone()) + .unwrap_or_else(|| DEFAULT_TRANSLATE_OPENAI_MODEL.to_string()), + translate_deepl_api_key: env.translate_deepl_api_key.clone(), + translate_deepl_base_url: env + .translate_deepl_base_url + .clone() + .or_else(|| file.translate_deepl_base_url.clone()), + translate_google_api_key: env.translate_google_api_key.clone(), + translate_google_base_url: env + .translate_google_base_url + .clone() + .or_else(|| file.translate_google_base_url.clone()), + translate_command_template: env + .translate_command_template + .clone() + .or_else(|| file.translate_command_template.clone()), + alass_path: env + .alass_path + .clone() + .map(PathBuf::from) + .or_else(|| file.alass_path.clone()) + .unwrap_or_else(|| PathBuf::from(DEFAULT_ALASS_PATH)), + ffmpeg_path: env + .ffmpeg_path + .clone() + .map(PathBuf::from) + .or_else(|| file.ffmpeg_path.clone()) + .unwrap_or_else(|| PathBuf::from(DEFAULT_FFMPEG_PATH)), + } +} + impl Config { /// Load from the process environment, and an optional TOML file named by /// `$ARR_CONFIG_FILE`. No file is not an error — every non-secret field @@ -281,6 +362,7 @@ impl Config { Some(path) => ConfigFile::load(Path::new(path))?, None => ConfigFile::default(), }; + let subtitles = resolve_subtitle_bootstrap(&env, &file); let bind_addr = match &env.bind_addr { Some(raw) => parse_bind_addr(raw, ENV_BIND_ADDR)?, @@ -347,36 +429,21 @@ impl Config { .or(file.ntfy_url) .unwrap_or_else(|| DEFAULT_NTFY_URL.to_string()), ntfy_operator_topic: env.ntfy_operator_topic.or(file.ntfy_operator_topic), - opensubtitles_api_key: env.opensubtitles_api_key, - opensubtitles_username: env.opensubtitles_username, - opensubtitles_password: env.opensubtitles_password, - podnapisi_username: env.podnapisi_username, - podnapisi_password: env.podnapisi_password, - translate_openai_api_key: env.translate_openai_api_key, - translate_openai_base_url: env - .translate_openai_base_url - .or(file.translate_openai_base_url), - translate_deepl_api_key: env.translate_deepl_api_key, - translate_deepl_base_url: env - .translate_deepl_base_url - .or(file.translate_deepl_base_url), - translate_google_api_key: env.translate_google_api_key, - translate_google_base_url: env - .translate_google_base_url - .or(file.translate_google_base_url), - translate_command_template: env - .translate_command_template - .or(file.translate_command_template), - alass_path: env - .alass_path - .map(PathBuf::from) - .or(file.alass_path) - .unwrap_or_else(|| PathBuf::from(DEFAULT_ALASS_PATH)), - ffmpeg_path: env - .ffmpeg_path - .map(PathBuf::from) - .or(file.ffmpeg_path) - .unwrap_or_else(|| PathBuf::from(DEFAULT_FFMPEG_PATH)), + opensubtitles_api_key: subtitles.opensubtitles_api_key, + opensubtitles_username: subtitles.opensubtitles_username, + opensubtitles_password: subtitles.opensubtitles_password, + podnapisi_username: subtitles.podnapisi_username, + podnapisi_password: subtitles.podnapisi_password, + translate_openai_api_key: subtitles.translate_openai_api_key, + translate_openai_base_url: subtitles.translate_openai_base_url, + translate_openai_model: subtitles.translate_openai_model, + translate_deepl_api_key: subtitles.translate_deepl_api_key, + translate_deepl_base_url: subtitles.translate_deepl_base_url, + translate_google_api_key: subtitles.translate_google_api_key, + translate_google_base_url: subtitles.translate_google_base_url, + translate_command_template: subtitles.translate_command_template, + alass_path: subtitles.alass_path, + ffmpeg_path: subtitles.ffmpeg_path, }) } } @@ -425,6 +492,10 @@ mod tests { assert_eq!(config.opensubtitles_api_key, None); assert_eq!(config.podnapisi_username, None); assert_eq!(config.translate_command_template, None); + assert_eq!( + config.translate_openai_model, + DEFAULT_TRANSLATE_OPENAI_MODEL + ); assert_eq!(config.alass_path, PathBuf::from(DEFAULT_ALASS_PATH)); assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH)); } @@ -547,6 +618,7 @@ prowlarr_url = "http://prowlarr.internal:9696" &path, r#" translate_openai_base_url = "http://llm.internal/v1" +translate_openai_model = "local-model" translate_command_template = "ssh box claude -p" alass_path = "/usr/local/bin/alass" "#, @@ -561,6 +633,7 @@ alass_path = "/usr/local/bin/alass" config.translate_openai_base_url.as_deref(), Some("http://llm.internal/v1") ); + assert_eq!(config.translate_openai_model, "local-model"); assert_eq!( config.translate_command_template.as_deref(), Some("ssh box claude -p") diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index c3f1d89..82edeaa 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -131,6 +131,7 @@ fn api_state( config.opensubtitles_username.clone(), config.opensubtitles_password.clone(), )) + .with_translation_backends(translation_backends(config)) .with_jellyfin(jellyfin) .with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone()))) } @@ -480,10 +481,6 @@ fn jellyfin_client(config: &Config) -> Result, username: Option, @@ -511,3 +508,95 @@ fn subtitle_providers( providers } + +/// The translation backends this deployment can offer (DESIGN.md §15, +/// issue #216). +/// +/// Which cargo features this binary was built with decides what could ever +/// be here (`compiled_engines`); credentials decide what actually is, same +/// split `subtitle_providers` makes for search. Which one of these a +/// translation *uses* is the `translation_engine` database setting, read per +/// request (#198) — this only decides which ids exist to be picked. +#[cfg_attr( + not(any( + feature = "translate-openai", + feature = "translate-deepl", + feature = "translate-google", + feature = "translate-command" + )), + allow(unused_variables, unused_mut) +)] +fn translation_backends(config: &Config) -> Vec> { + let mut backends: Vec> = Vec::new(); + + #[cfg(feature = "translate-openai")] + { + let openai_config = arr_subs::OpenAiConfig { + api_key: config.translate_openai_api_key.clone(), + }; + let backend = match &config.translate_openai_base_url { + Some(base_url) => arr_subs::OpenAi::with_base_url( + config.translate_openai_model.clone(), + openai_config, + base_url, + ), + None => arr_subs::OpenAi::new(config.translate_openai_model.clone(), openai_config), + }; + match backend { + Ok(backend) => backends.push(std::sync::Arc::new(backend)), + Err(error) => tracing::warn!(%error, "OpenAI-compatible translator not available"), + } + } + + #[cfg(feature = "translate-deepl")] + { + if let Some(auth_key) = config.translate_deepl_api_key.clone() { + let deepl_config = arr_subs::DeepLConfig { auth_key }; + let backend = match &config.translate_deepl_base_url { + Some(base_url) => arr_subs::DeepL::with_base_url(deepl_config, base_url), + None => arr_subs::DeepL::new(deepl_config), + }; + match backend { + Ok(backend) => backends.push(std::sync::Arc::new(backend)), + Err(error) => tracing::warn!(%error, "DeepL not available"), + } + } else { + tracing::info!("no DeepL auth key configured; that translator is off"); + } + } + + #[cfg(feature = "translate-google")] + { + if let Some(api_key) = config.translate_google_api_key.clone() { + let google_config = arr_subs::GoogleConfig { api_key }; + let backend = match &config.translate_google_base_url { + Some(base_url) => arr_subs::Google::with_base_url(google_config, base_url), + None => arr_subs::Google::new(google_config), + }; + match backend { + Ok(backend) => backends.push(std::sync::Arc::new(backend)), + Err(error) => tracing::warn!(%error, "Google Translate not available"), + } + } else { + tracing::info!("no Google Translate API key configured; that translator is off"); + } + } + + #[cfg(feature = "translate-command")] + { + if let Some(template) = config.translate_command_template.clone() { + let command_config = arr_subs::CommandConfig { + template, + timeout: arr_subs::COMMAND_DEFAULT_TIMEOUT, + }; + match arr_subs::Command::new(command_config) { + Ok(backend) => backends.push(std::sync::Arc::new(backend)), + Err(error) => tracing::warn!(%error, "remote-command translator not available"), + } + } else { + tracing::info!("no remote-command template configured; that translator is off"); + } + } + + backends +}