feat(arr): add subtitle bootstrap config
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user