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:
@@ -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 }
|
||||
|
||||
+103
-30
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
translate_openai_model: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_deepl_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
translate_google_base_url: Option<String>,
|
||||
@@ -169,6 +176,7 @@ pub struct EnvOverrides {
|
||||
pub podnapisi_password: Option<String>,
|
||||
pub translate_openai_api_key: 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_base_url: Option<String>,
|
||||
pub translate_google_api_key: Option<String>,
|
||||
@@ -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<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>,
|
||||
/// `None` means the backend's own built-in default when it lands (#192).
|
||||
pub translate_deepl_base_url: Option<String>,
|
||||
@@ -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<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 {
|
||||
/// 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")
|
||||
|
||||
@@ -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<arr_api::jellyfin::JellyfinClient,
|
||||
/// search *runs* is the `providers_enabled` setting the API reads per
|
||||
/// 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.
|
||||
///
|
||||
/// 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(
|
||||
opensubtitles_api_key: Option<String>,
|
||||
username: Option<String>,
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user