Merge #198: configure subtitles via env and database

Closes #198
This commit is contained in:
Miguel Palhas
2026-08-24 22:36:12 +01:00
9 changed files with 782 additions and 1 deletions
+201
View File
@@ -30,6 +30,23 @@ pub const ENV_JELLYFIN_URL: &str = "ARR_JELLYFIN_URL";
pub const ENV_JELLYFIN_API_KEY: &str = "ARR_JELLYFIN_API_KEY";
pub const ENV_NTFY_URL: &str = "ARR_NTFY_URL";
pub const ENV_NTFY_OPERATOR_TOPIC: &str = "ARR_NTFY_OPERATOR_TOPIC";
// DESIGN.md §15 subtitle bootstrap. Credentials are secrets (env-only,
// below); base URLs, the remote-command template and the binary paths may
// also sit in the config file.
pub const ENV_OPENSUBTITLES_API_KEY: &str = "ARR_OPENSUBTITLES_API_KEY";
pub const ENV_OPENSUBTITLES_USERNAME: &str = "ARR_OPENSUBTITLES_USERNAME";
pub const ENV_OPENSUBTITLES_PASSWORD: &str = "ARR_OPENSUBTITLES_PASSWORD";
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_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";
pub const ENV_TRANSLATE_GOOGLE_BASE_URL: &str = "ARR_TRANSLATE_GOOGLE_BASE_URL";
pub const ENV_TRANSLATE_COMMAND_TEMPLATE: &str = "ARR_TRANSLATE_COMMAND_TEMPLATE";
pub const ENV_ALASS_PATH: &str = "ARR_ALASS_PATH";
pub const ENV_FFMPEG_PATH: &str = "ARR_FFMPEG_PATH";
pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
@@ -45,6 +62,8 @@ pub const DEFAULT_SEED_RATIO_LIMIT: f64 = 1.0;
pub const DEFAULT_SEED_IDLE_LIMIT_MINUTES: u64 = 4320;
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";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
@@ -92,6 +111,18 @@ struct ConfigFile {
ntfy_url: Option<String>,
#[serde(default)]
ntfy_operator_topic: Option<String>,
#[serde(default)]
translate_openai_base_url: Option<String>,
#[serde(default)]
translate_deepl_base_url: Option<String>,
#[serde(default)]
translate_google_base_url: Option<String>,
#[serde(default)]
translate_command_template: Option<String>,
#[serde(default)]
alass_path: Option<PathBuf>,
#[serde(default)]
ffmpeg_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
@@ -131,6 +162,20 @@ pub struct EnvOverrides {
pub jellyfin_api_key: Option<String>,
pub ntfy_url: Option<String>,
pub ntfy_operator_topic: Option<String>,
pub opensubtitles_api_key: Option<String>,
pub opensubtitles_username: Option<String>,
pub opensubtitles_password: Option<String>,
pub podnapisi_username: Option<String>,
pub podnapisi_password: Option<String>,
pub translate_openai_api_key: Option<String>,
pub translate_openai_base_url: Option<String>,
pub translate_deepl_api_key: Option<String>,
pub translate_deepl_base_url: Option<String>,
pub translate_google_api_key: Option<String>,
pub translate_google_base_url: Option<String>,
pub translate_command_template: Option<String>,
pub alass_path: Option<String>,
pub ffmpeg_path: Option<String>,
}
impl EnvOverrides {
@@ -152,6 +197,20 @@ impl EnvOverrides {
jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(),
ntfy_url: std::env::var(ENV_NTFY_URL).ok(),
ntfy_operator_topic: std::env::var(ENV_NTFY_OPERATOR_TOPIC).ok(),
opensubtitles_api_key: std::env::var(ENV_OPENSUBTITLES_API_KEY).ok(),
opensubtitles_username: std::env::var(ENV_OPENSUBTITLES_USERNAME).ok(),
opensubtitles_password: std::env::var(ENV_OPENSUBTITLES_PASSWORD).ok(),
podnapisi_username: std::env::var(ENV_PODNAPISI_USERNAME).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_base_url: std::env::var(ENV_TRANSLATE_OPENAI_BASE_URL).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(),
translate_google_base_url: std::env::var(ENV_TRANSLATE_GOOGLE_BASE_URL).ok(),
translate_command_template: std::env::var(ENV_TRANSLATE_COMMAND_TEMPLATE).ok(),
alass_path: std::env::var(ENV_ALASS_PATH).ok(),
ffmpeg_path: std::env::var(ENV_FFMPEG_PATH).ok(),
}
}
}
@@ -182,6 +241,31 @@ pub struct Config {
/// *broken*. `None` means those two notifications are skipped — there is
/// no sensible default topic name to fall back to.
pub ntfy_operator_topic: Option<String>,
/// §15. `None` means OpenSubtitles.com search runs unauthenticated,
/// which its API allows at a lower rate.
pub opensubtitles_api_key: Option<String>,
pub opensubtitles_username: Option<String>,
pub opensubtitles_password: Option<String>,
pub podnapisi_username: Option<String>,
pub podnapisi_password: Option<String>,
pub translate_openai_api_key: Option<String>,
/// §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>,
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>,
pub translate_google_api_key: Option<String>,
/// `None` means the backend's own built-in default when it lands (#193).
pub translate_google_base_url: Option<String>,
/// §15. The generic remote-command backend's invocation template, e.g.
/// `ssh box claude -p`. `None` means that backend is unconfigured.
pub translate_command_template: Option<String>,
/// §15. `alass` runs on every fetched and every translated subtitle; a
/// bare name resolves through `PATH`, matching `arr-probe`'s `ffprobe`.
pub alass_path: PathBuf,
/// §15. Extracts text-format embedded tracks to sidecar SRTs.
pub ffmpeg_path: PathBuf,
}
impl Config {
@@ -263,6 +347,36 @@ 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)),
})
}
}
@@ -308,6 +422,11 @@ mod tests {
assert_eq!(config.prowlarr_api_key, None);
assert_eq!(config.tmdb_api_key, None);
assert_eq!(config.jellyfin_api_key, None);
assert_eq!(config.opensubtitles_api_key, None);
assert_eq!(config.podnapisi_username, None);
assert_eq!(config.translate_command_template, None);
assert_eq!(config.alass_path, PathBuf::from(DEFAULT_ALASS_PATH));
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
}
#[test]
@@ -376,6 +495,88 @@ prowlarr_url = "http://prowlarr.internal:9696"
assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3"));
}
/// §15: `OpenSubtitles`, Podnapisi and every translator's credentials are
/// secrets, so this mirrors [`secrets_come_only_from_env`] for them.
#[test]
fn subtitle_secrets_come_only_from_env() {
let env = EnvOverrides {
opensubtitles_api_key: Some("os-key".into()),
opensubtitles_username: Some("os-user".into()),
opensubtitles_password: Some("os-pass".into()),
podnapisi_username: Some("pod-user".into()),
podnapisi_password: Some("pod-pass".into()),
translate_openai_api_key: Some("oa-key".into()),
translate_deepl_api_key: Some("dl-key".into()),
translate_google_api_key: Some("gg-key".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.opensubtitles_api_key.as_deref(), Some("os-key"));
assert_eq!(config.opensubtitles_username.as_deref(), Some("os-user"));
assert_eq!(config.opensubtitles_password.as_deref(), Some("os-pass"));
assert_eq!(config.podnapisi_username.as_deref(), Some("pod-user"));
assert_eq!(config.podnapisi_password.as_deref(), Some("pod-pass"));
assert_eq!(config.translate_openai_api_key.as_deref(), Some("oa-key"));
assert_eq!(config.translate_deepl_api_key.as_deref(), Some("dl-key"));
assert_eq!(config.translate_google_api_key.as_deref(), Some("gg-key"));
}
#[test]
fn a_subtitle_secret_in_the_config_file_is_a_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "opensubtitles_api_key = \"leaked\"\n").unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
assert!(matches!(
Config::resolve(env),
Err(ConfigError::TomlDecode(_))
));
}
/// §15: base URLs, the remote-command template and the binary paths are
/// not secrets, so the file, the environment and their precedence all
/// apply the same way they do for `jellyfin_url` and friends.
#[test]
fn subtitle_non_secret_bootstrap_comes_from_file_or_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(
&path,
r#"
translate_openai_base_url = "http://llm.internal/v1"
translate_command_template = "ssh box claude -p"
alass_path = "/usr/local/bin/alass"
"#,
)
.unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
let config = Config::resolve(env.clone()).unwrap();
assert_eq!(
config.translate_openai_base_url.as_deref(),
Some("http://llm.internal/v1")
);
assert_eq!(
config.translate_command_template.as_deref(),
Some("ssh box claude -p")
);
assert_eq!(config.alass_path, PathBuf::from("/usr/local/bin/alass"));
// ffmpeg_path was not set anywhere, so it still defaults.
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
let config = Config::resolve(EnvOverrides {
ffmpeg_path: Some("/opt/bin/ffmpeg".into()),
..env
})
.unwrap();
assert_eq!(config.ffmpeg_path, PathBuf::from("/opt/bin/ffmpeg"));
}
#[test]
fn tmdb_url_is_an_env_only_seam() {
let config = Config::resolve(EnvOverrides::default()).unwrap();