feat(arr): wire translation backends into daemon

Forward each translate-* feature from arr-daemon to arr-subs, add the
missing OpenAI model bootstrap key, and construct the compiled and
credentialed backends at startup so the translate endpoint stops
answering 503 unconditionally.
This commit is contained in:
Miguel Palhas
2026-08-25 02:01:55 +01:00
parent f1a58c0810
commit e49bc2736d
3 changed files with 206 additions and 34 deletions
+10
View File
@@ -10,6 +10,16 @@ publish = false
name = "arr" name = "arr"
path = "src/main.rs" 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] [dependencies]
arr-api = { workspace = true } arr-api = { workspace = true }
arr-compat = { workspace = true } arr-compat = { workspace = true }
+103 -30
View File
@@ -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_PODNAPISI_PASSWORD: &str = "ARR_PODNAPISI_PASSWORD";
pub const ENV_TRANSLATE_OPENAI_API_KEY: &str = "ARR_TRANSLATE_OPENAI_API_KEY"; 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_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_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_DEEPL_BASE_URL: &str = "ARR_TRANSLATE_DEEPL_BASE_URL";
pub const ENV_TRANSLATE_GOOGLE_API_KEY: &str = "ARR_TRANSLATE_GOOGLE_API_KEY"; 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_NTFY_URL: &str = "http://localhost";
pub const DEFAULT_ALASS_PATH: &str = "alass"; pub const DEFAULT_ALASS_PATH: &str = "alass";
pub const DEFAULT_FFMPEG_PATH: &str = "ffmpeg"; 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)] #[derive(Debug, thiserror::Error)]
pub enum ConfigError { pub enum ConfigError {
@@ -114,6 +119,8 @@ struct ConfigFile {
#[serde(default)] #[serde(default)]
translate_openai_base_url: Option<String>, translate_openai_base_url: Option<String>,
#[serde(default)] #[serde(default)]
translate_openai_model: Option<String>,
#[serde(default)]
translate_deepl_base_url: Option<String>, translate_deepl_base_url: Option<String>,
#[serde(default)] #[serde(default)]
translate_google_base_url: Option<String>, translate_google_base_url: Option<String>,
@@ -169,6 +176,7 @@ pub struct EnvOverrides {
pub podnapisi_password: Option<String>, pub podnapisi_password: Option<String>,
pub translate_openai_api_key: Option<String>, pub translate_openai_api_key: Option<String>,
pub translate_openai_base_url: Option<String>, pub translate_openai_base_url: Option<String>,
pub translate_openai_model: Option<String>,
pub translate_deepl_api_key: Option<String>, pub translate_deepl_api_key: Option<String>,
pub translate_deepl_base_url: Option<String>, pub translate_deepl_base_url: Option<String>,
pub translate_google_api_key: Option<String>, pub translate_google_api_key: Option<String>,
@@ -204,6 +212,7 @@ impl EnvOverrides {
podnapisi_password: std::env::var(ENV_PODNAPISI_PASSWORD).ok(), podnapisi_password: std::env::var(ENV_PODNAPISI_PASSWORD).ok(),
translate_openai_api_key: std::env::var(ENV_TRANSLATE_OPENAI_API_KEY).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_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_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_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(), 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 /// §15. No default: an OpenAI-compatible endpoint has no universal
/// address the way `DeepL` or Google Translate do. /// address the way `DeepL` or Google Translate do.
pub translate_openai_base_url: Option<String>, pub translate_openai_base_url: Option<String>,
/// §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<String>, pub translate_deepl_api_key: Option<String>,
/// `None` means the backend's own built-in default when it lands (#192). /// `None` means the backend's own built-in default when it lands (#192).
pub translate_deepl_base_url: Option<String>, pub translate_deepl_base_url: Option<String>,
@@ -268,6 +280,75 @@ pub struct Config {
pub ffmpeg_path: PathBuf, 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<String>,
opensubtitles_username: Option<String>,
opensubtitles_password: Option<String>,
podnapisi_username: Option<String>,
podnapisi_password: Option<String>,
translate_openai_api_key: Option<String>,
translate_openai_base_url: Option<String>,
translate_openai_model: String,
translate_deepl_api_key: Option<String>,
translate_deepl_base_url: Option<String>,
translate_google_api_key: Option<String>,
translate_google_base_url: Option<String>,
translate_command_template: Option<String>,
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 { impl Config {
/// Load from the process environment, and an optional TOML file named by /// 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 /// `$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))?, Some(path) => ConfigFile::load(Path::new(path))?,
None => ConfigFile::default(), None => ConfigFile::default(),
}; };
let subtitles = resolve_subtitle_bootstrap(&env, &file);
let bind_addr = match &env.bind_addr { let bind_addr = match &env.bind_addr {
Some(raw) => parse_bind_addr(raw, ENV_BIND_ADDR)?, Some(raw) => parse_bind_addr(raw, ENV_BIND_ADDR)?,
@@ -347,36 +429,21 @@ impl Config {
.or(file.ntfy_url) .or(file.ntfy_url)
.unwrap_or_else(|| DEFAULT_NTFY_URL.to_string()), .unwrap_or_else(|| DEFAULT_NTFY_URL.to_string()),
ntfy_operator_topic: env.ntfy_operator_topic.or(file.ntfy_operator_topic), ntfy_operator_topic: env.ntfy_operator_topic.or(file.ntfy_operator_topic),
opensubtitles_api_key: env.opensubtitles_api_key, opensubtitles_api_key: subtitles.opensubtitles_api_key,
opensubtitles_username: env.opensubtitles_username, opensubtitles_username: subtitles.opensubtitles_username,
opensubtitles_password: env.opensubtitles_password, opensubtitles_password: subtitles.opensubtitles_password,
podnapisi_username: env.podnapisi_username, podnapisi_username: subtitles.podnapisi_username,
podnapisi_password: env.podnapisi_password, podnapisi_password: subtitles.podnapisi_password,
translate_openai_api_key: env.translate_openai_api_key, translate_openai_api_key: subtitles.translate_openai_api_key,
translate_openai_base_url: env translate_openai_base_url: subtitles.translate_openai_base_url,
.translate_openai_base_url translate_openai_model: subtitles.translate_openai_model,
.or(file.translate_openai_base_url), translate_deepl_api_key: subtitles.translate_deepl_api_key,
translate_deepl_api_key: env.translate_deepl_api_key, translate_deepl_base_url: subtitles.translate_deepl_base_url,
translate_deepl_base_url: env translate_google_api_key: subtitles.translate_google_api_key,
.translate_deepl_base_url translate_google_base_url: subtitles.translate_google_base_url,
.or(file.translate_deepl_base_url), translate_command_template: subtitles.translate_command_template,
translate_google_api_key: env.translate_google_api_key, alass_path: subtitles.alass_path,
translate_google_base_url: env ffmpeg_path: subtitles.ffmpeg_path,
.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)),
}) })
} }
} }
@@ -425,6 +492,10 @@ mod tests {
assert_eq!(config.opensubtitles_api_key, None); assert_eq!(config.opensubtitles_api_key, None);
assert_eq!(config.podnapisi_username, None); assert_eq!(config.podnapisi_username, None);
assert_eq!(config.translate_command_template, 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.alass_path, PathBuf::from(DEFAULT_ALASS_PATH));
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH)); assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
} }
@@ -547,6 +618,7 @@ prowlarr_url = "http://prowlarr.internal:9696"
&path, &path,
r#" r#"
translate_openai_base_url = "http://llm.internal/v1" translate_openai_base_url = "http://llm.internal/v1"
translate_openai_model = "local-model"
translate_command_template = "ssh box claude -p" translate_command_template = "ssh box claude -p"
alass_path = "/usr/local/bin/alass" alass_path = "/usr/local/bin/alass"
"#, "#,
@@ -561,6 +633,7 @@ alass_path = "/usr/local/bin/alass"
config.translate_openai_base_url.as_deref(), config.translate_openai_base_url.as_deref(),
Some("http://llm.internal/v1") Some("http://llm.internal/v1")
); );
assert_eq!(config.translate_openai_model, "local-model");
assert_eq!( assert_eq!(
config.translate_command_template.as_deref(), config.translate_command_template.as_deref(),
Some("ssh box claude -p") Some("ssh box claude -p")
+93 -4
View File
@@ -131,6 +131,7 @@ fn api_state(
config.opensubtitles_username.clone(), config.opensubtitles_username.clone(),
config.opensubtitles_password.clone(), config.opensubtitles_password.clone(),
)) ))
.with_translation_backends(translation_backends(config))
.with_jellyfin(jellyfin) .with_jellyfin(jellyfin)
.with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone()))) .with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone())))
} }
@@ -480,10 +481,6 @@ fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient,
/// search *runs* is the `providers_enabled` setting the API reads per /// search *runs* is the `providers_enabled` setting the API reads per
/// request. OpenSubtitles.com needs a registered API key to be called at all, /// request. OpenSubtitles.com needs a registered API key to be called at all,
/// so without one it is not offered; Podnapisi is anonymous and always is. /// so without one it is not offered; Podnapisi is anonymous and always is.
///
/// Translation backends are not wired: none is implemented yet (#191-#193),
/// and `AppState` defaults to an empty registry, which makes a manual
/// translation answer "no such engine" rather than fail obscurely.
fn subtitle_providers( fn subtitle_providers(
opensubtitles_api_key: Option<String>, opensubtitles_api_key: Option<String>,
username: Option<String>, username: Option<String>,
@@ -511,3 +508,95 @@ fn subtitle_providers(
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<std::sync::Arc<dyn arr_subs::Backend>> {
let mut backends: Vec<std::sync::Arc<dyn arr_subs::Backend>> = 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
}