feat(arr): add an env-only Podnapisi URL seam

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.
This commit is contained in:
Miguel Palhas
2026-08-25 05:25:25 +01:00
parent da1a932459
commit 5fc6854705
2 changed files with 53 additions and 1 deletions
+43
View File
@@ -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<String>,
pub jellyfin_url: Option<String>,
pub jellyfin_api_key: Option<String>,
pub podnapisi_url: Option<String>,
pub ntfy_url: Option<String>,
pub ntfy_operator_topic: Option<String>,
pub opensubtitles_api_key: Option<String>,
@@ -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<String>,
pub jellyfin_url: String,
pub jellyfin_api_key: Option<String>,
/// E2E seam only, env-only. `None` means Podnapisi's built-in default
/// address; see [`ENV_PODNAPISI_URL`].
pub podnapisi_url: Option<String>,
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();
+10 -1
View File
@@ -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<String>,
username: Option<String>,
password: Option<String>,
podnapisi_url: Option<String>,
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = 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"),
}