From 5fc685470537b54cf2057c07b4309d933a7d256f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 05:25:25 +0100 Subject: [PATCH] feat(arr): add an env-only Podnapisi URL seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARR_PODNAPISI_URL, same shape as the existing tmdb_url seam: a test harness can point the provider at a wiremock fake without touching DESIGN.md §10's config surface. --- crates/arr-daemon/src/config.rs | 43 +++++++++++++++++++++++++++++++++ crates/arr-daemon/src/main.rs | 11 ++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/arr-daemon/src/config.rs b/crates/arr-daemon/src/config.rs index 5bc840d..e7e9ce7 100644 --- a/crates/arr-daemon/src/config.rs +++ b/crates/arr-daemon/src/config.rs @@ -48,6 +48,11 @@ 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"; +/// E2E seam only, env-only, same shape as [`ENV_TMDB_URL`]: `None` means +/// Podnapisi's real address. Kept out of the config file for the same +/// reason `tmdb_url` is — DESIGN.md §10 has no business exposing a seam that +/// only a test harness uses. +pub const ENV_PODNAPISI_URL: &str = "ARR_PODNAPISI_URL"; pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878"; pub const DEFAULT_DATABASE_PATH: &str = "arr.db"; @@ -167,6 +172,7 @@ pub struct EnvOverrides { pub tmdb_url: Option, pub jellyfin_url: Option, pub jellyfin_api_key: Option, + pub podnapisi_url: Option, pub ntfy_url: Option, pub ntfy_operator_topic: Option, pub opensubtitles_api_key: Option, @@ -203,6 +209,7 @@ impl EnvOverrides { tmdb_url: std::env::var(ENV_TMDB_URL).ok(), jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(), jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(), + podnapisi_url: std::env::var(ENV_PODNAPISI_URL).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(), @@ -245,6 +252,9 @@ pub struct Config { pub tmdb_url: Option, pub jellyfin_url: String, pub jellyfin_api_key: Option, + /// E2E seam only, env-only. `None` means Podnapisi's built-in default + /// address; see [`ENV_PODNAPISI_URL`]. + pub podnapisi_url: Option, pub ntfy_url: String, /// The operator's ntfy topic (DESIGN.md §9.5) for *needs a decision* and /// *broken*. `None` means those two notifications are skipped — there is @@ -424,6 +434,7 @@ impl Config { .or(file.jellyfin_url) .unwrap_or_else(|| DEFAULT_JELLYFIN_URL.to_string()), jellyfin_api_key: env.jellyfin_api_key, + podnapisi_url: env.podnapisi_url, ntfy_url: env .ntfy_url .or(file.ntfy_url) @@ -483,6 +494,7 @@ mod tests { DEFAULT_SEED_IDLE_LIMIT_MINUTES ); assert_eq!(config.jellyfin_url, DEFAULT_JELLYFIN_URL); + assert_eq!(config.podnapisi_url, None); assert!(config.tracker_seeding.is_empty()); assert_eq!(config.ntfy_url, DEFAULT_NTFY_URL); assert_eq!(config.ntfy_operator_topic, None); @@ -675,6 +687,37 @@ alass_path = "/usr/local/bin/alass" )); } + /// Same env-only seam as `tmdb_url`, for the same reason: `arr-e2e` needs + /// to point Podnapisi at a `wiremock` fake without a live-tracker risk + /// creeping into the config file (DESIGN.md §15, §10). + #[test] + fn podnapisi_url_is_an_env_only_seam() { + let config = Config::resolve(EnvOverrides::default()).unwrap(); + assert_eq!(config.podnapisi_url, None); + + let env = EnvOverrides { + podnapisi_url: Some("http://127.0.0.1:9/subtitles".into()), + ..EnvOverrides::default() + }; + let config = Config::resolve(env).unwrap(); + assert_eq!( + config.podnapisi_url.as_deref(), + Some("http://127.0.0.1:9/subtitles") + ); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("arr.toml"); + std::fs::write(&path, "podnapisi_url = \"http://127.0.0.1:9/subtitles\"\n").unwrap(); + let env = EnvOverrides { + config_file: Some(path.to_string_lossy().into_owned()), + ..EnvOverrides::default() + }; + assert!(matches!( + Config::resolve(env), + Err(ConfigError::TomlDecode(_)) + )); + } + #[test] fn a_secret_in_the_config_file_is_a_parse_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 6a73f75..0c21380 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -133,6 +133,7 @@ fn api_state( config.opensubtitles_api_key.clone(), config.opensubtitles_username.clone(), config.opensubtitles_password.clone(), + config.podnapisi_url.clone(), )) .with_translation_backends(translators.backends.clone()) .with_jellyfin(jellyfin) @@ -541,6 +542,7 @@ fn subtitle_action( config.opensubtitles_api_key.clone(), config.opensubtitles_username.clone(), config.opensubtitles_password.clone(), + config.podnapisi_url.clone(), ), translators.backends.clone(), arr_subs::Syncer::new().with_binary(config.alass_path.clone()), @@ -564,10 +566,17 @@ fn subtitle_providers( opensubtitles_api_key: Option, username: Option, password: Option, + podnapisi_url: Option, ) -> Vec> { let mut providers: Vec> = Vec::new(); - match arr_subs::Podnapisi::new() { + // `podnapisi_url` is the e2e-only seam (`ARR_PODNAPISI_URL`); absent, this + // is the real Podnapisi.net. + let mut builder = arr_subs::Podnapisi::builder(); + if let Some(url) = podnapisi_url { + builder = builder.base_url(url); + } + match builder.build() { Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)), Err(error) => tracing::warn!(%error, "Podnapisi not available"), }