Files
arr/crates/arr-daemon/src/config.rs
T
Miguel PalhasandClaude Opus 5 ab4f95b166
ci / web (push) Successful in 32s
ci / rust (push) Successful in 3m26s
e2e / e2e (push) Failing after 5m2s
ci / image (push) Successful in 4m1s
feat(dl): replace Transmission with qBittorrent
qBittorrent's add call answers `Ok.` and nothing else — no hash, no name,
no duplicate signal — so arr derives the v1 infohash from the magnet or the
`.torrent` bytes before the call and looks the torrent up by it. That also
drops Transmission's numeric torrent id: the hash is the only identity now.

The reaper needs "stopped because a share limit was reached", and
qBittorrent's state field cannot tell that apart from a hand-paused torrent.
So the ratio and idle counters are checked against the limits arr set, and a
torrent stopped by a global limit reads as still seeding rather than being
deleted.

The WebUI needs a login, so `ARR_QBITTORRENT_USERNAME` and
`ARR_QBITTORRENT_PASSWORD` join the env-only secrets; leaving both unset is
valid for an instance that whitelists arr's subnet.

Verified against qBittorrent 5 (WebAPI 2.15.1) in a container: it rejects
`setShareLimits` without `shareLimitAction`, which 4.x ignores, so it is
always sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:13:50 +01:00

794 lines
33 KiB
Rust

//! Bootstrap configuration. See DESIGN.md §10: only these settings come from
//! config/env; everything else lives in the database.
//!
//! Precedence per field: env > file > default. Every non-secret field has a
//! default, and the config file itself is optional, so an empty environment
//! and no file both resolve to a usable config. Secrets (API keys) are read
//! only from the environment: [`ConfigFile`] has no fields for them, and
//! `deny_unknown_fields` turns an accidental key in the file into a parse
//! error instead of silently ignoring it.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const ENV_CONFIG_FILE: &str = "ARR_CONFIG_FILE";
pub const ENV_BIND_ADDR: &str = "ARR_BIND_ADDR";
pub const ENV_DATABASE_PATH: &str = "ARR_DATABASE_PATH";
pub const ENV_MEDIA_ROOT: &str = "ARR_MEDIA_ROOT";
pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL";
pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY";
pub const ENV_QBITTORRENT_URL: &str = "ARR_QBITTORRENT_URL";
// The WebUI password is a secret, so both halves are env-only (§10) and
// neither has a `ConfigFile` field. Leaving them unset is valid: it is the
// right shape for an instance that bypasses authentication for arr's address.
pub const ENV_QBITTORRENT_USERNAME: &str = "ARR_QBITTORRENT_USERNAME";
pub const ENV_QBITTORRENT_PASSWORD: &str = "ARR_QBITTORRENT_PASSWORD";
pub const ENV_DOWNLOAD_DIR: &str = "ARR_DOWNLOAD_DIR";
pub const ENV_SEED_RATIO_LIMIT: &str = "ARR_SEED_RATIO_LIMIT";
pub const ENV_SEED_IDLE_LIMIT_MINUTES: &str = "ARR_SEED_IDLE_LIMIT_MINUTES";
pub const ENV_TMDB_API_KEY: &str = "ARR_TMDB_API_KEY";
pub const ENV_TMDB_URL: &str = "ARR_TMDB_URL";
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";
// The OpenAI-compatible backend's base URL and model are `subtitle_settings`
// rows, not bootstrap keys (#220): that backend is anything speaking the
// shape, and which endpoint is in use is something the operator changes from
// `/settings`. Only the key is here, because §10 keeps secrets out of the
// database — and an endpoint needing no key at all is valid.
pub const ENV_TRANSLATE_OPENAI_API_KEY: &str = "ARR_TRANSLATE_OPENAI_API_KEY";
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";
pub const DEFAULT_MEDIA_ROOT: &str = "/mnt/media";
pub const DEFAULT_PROWLARR_URL: &str = "http://localhost:9696";
pub const DEFAULT_QBITTORRENT_URL: &str = "http://localhost:8080";
/// qBittorrent's own view of the download directory (DESIGN.md §3). It
/// shares the media dataset with the library so hardlinks work (§7.2).
pub const DEFAULT_DOWNLOAD_DIR: &str = "/mnt/media/qbittorrent/complete";
/// Seeding obligation defaults (§7.3), applied to every torrent at add time
/// and enforced by qBittorrent. Per-tracker rules are issue #25.
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 {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("toml decode: {0}")]
TomlDecode(#[from] toml::de::Error),
#[error("invalid {env} ({input:?}): {source}")]
InvalidBindAddr {
env: &'static str,
input: String,
source: std::net::AddrParseError,
},
#[error("invalid {env} ({input:?}): expected a number")]
InvalidNumber { env: &'static str, input: String },
#[error("invalid tracker_seeding key {0:?}: expected a Prowlarr indexer ID")]
InvalidTrackerId(String),
}
/// On-disk representation. Non-secret fields only — see the module docs.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
#[serde(default)]
bind_addr: Option<SocketAddr>,
#[serde(default)]
database_path: Option<PathBuf>,
#[serde(default)]
media_root: Option<PathBuf>,
#[serde(default)]
prowlarr_url: Option<String>,
#[serde(default)]
qbittorrent_url: Option<String>,
#[serde(default)]
download_dir: Option<PathBuf>,
#[serde(default)]
seed_ratio_limit: Option<f64>,
#[serde(default)]
seed_idle_limit_minutes: Option<u64>,
#[serde(default)]
tracker_seeding: HashMap<String, TrackerSeedingRule>,
#[serde(default)]
jellyfin_url: Option<String>,
#[serde(default)]
ntfy_url: Option<String>,
#[serde(default)]
ntfy_operator_topic: 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)]
#[serde(deny_unknown_fields)]
pub struct TrackerSeedingRule {
pub ratio: f64,
pub min_seed_time: u64,
}
impl ConfigFile {
fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path)?;
Ok(toml::from_str(&raw)?)
}
}
/// Raw env var readout, so tests can inject values without mutating the
/// process environment (which races other tests in the same binary).
#[derive(Debug, Clone, Default)]
pub struct EnvOverrides {
pub config_file: Option<String>,
pub bind_addr: Option<String>,
pub database_path: Option<String>,
pub media_root: Option<String>,
pub prowlarr_url: Option<String>,
pub prowlarr_api_key: Option<String>,
pub qbittorrent_url: Option<String>,
pub qbittorrent_username: Option<String>,
pub qbittorrent_password: Option<String>,
pub download_dir: Option<String>,
pub seed_ratio_limit: Option<String>,
pub seed_idle_limit_minutes: Option<String>,
pub tmdb_api_key: Option<String>,
pub tmdb_url: Option<String>,
pub jellyfin_url: Option<String>,
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 translate_openai_api_key: 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 {
pub fn from_env() -> Self {
Self {
config_file: std::env::var(ENV_CONFIG_FILE).ok(),
bind_addr: std::env::var(ENV_BIND_ADDR).ok(),
database_path: std::env::var(ENV_DATABASE_PATH).ok(),
media_root: std::env::var(ENV_MEDIA_ROOT).ok(),
prowlarr_url: std::env::var(ENV_PROWLARR_URL).ok(),
prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(),
qbittorrent_url: std::env::var(ENV_QBITTORRENT_URL).ok(),
qbittorrent_username: std::env::var(ENV_QBITTORRENT_USERNAME).ok(),
qbittorrent_password: std::env::var(ENV_QBITTORRENT_PASSWORD).ok(),
download_dir: std::env::var(ENV_DOWNLOAD_DIR).ok(),
seed_ratio_limit: std::env::var(ENV_SEED_RATIO_LIMIT).ok(),
seed_idle_limit_minutes: std::env::var(ENV_SEED_IDLE_LIMIT_MINUTES).ok(),
tmdb_api_key: std::env::var(ENV_TMDB_API_KEY).ok(),
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(),
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(),
translate_openai_api_key: std::env::var(ENV_TRANSLATE_OPENAI_API_KEY).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(),
}
}
}
/// Resolved bootstrap configuration. See DESIGN.md §10.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub bind_addr: SocketAddr,
pub database_path: PathBuf,
pub media_root: PathBuf,
pub prowlarr_url: String,
pub prowlarr_api_key: Option<String>,
pub qbittorrent_url: String,
/// The `WebUI` login. `None` for an instance that bypasses authentication
/// for arr's address; a username without a password (or the reverse) is
/// treated as no credentials at all.
pub qbittorrent_username: Option<String>,
pub qbittorrent_password: Option<String>,
/// Where qBittorrent puts completed downloads, in qBittorrent's own
/// namespace (§7.1).
pub download_dir: PathBuf,
pub seed_ratio_limit: f64,
pub seed_idle_limit_minutes: u64,
pub tracker_seeding: HashMap<i64, TrackerSeedingRule>,
pub tmdb_api_key: Option<String>,
/// E2E seam only, env-only. `None` means the client's built-in TMDB
/// address; DESIGN.md §10 keeps the real URL out of configuration.
pub tmdb_url: Option<String>,
pub jellyfin_url: String,
pub jellyfin_api_key: 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
/// 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>,
/// §15. `None` is valid: `llama.cpp` and a local gateway serve without
/// authentication. Where that backend points and which model it names
/// are `subtitle_settings` rows, read at start-up and re-read on every
/// edit (#220), not bootstrap config.
pub translate_openai_api_key: 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,
}
/// §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>,
translate_openai_api_key: Option<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(),
translate_openai_api_key: env.translate_openai_api_key.clone(),
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
/// has a default.
pub fn load() -> Result<Self, ConfigError> {
Self::resolve(EnvOverrides::from_env())
}
fn resolve(env: EnvOverrides) -> Result<Self, ConfigError> {
let file = match &env.config_file {
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)?,
None => file
.bind_addr
.unwrap_or_else(|| DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")),
};
let tracker_seeding = file
.tracker_seeding
.iter()
.map(|(id, &rule)| {
id.parse::<i64>()
.map(|id| (id, rule))
.map_err(|_| ConfigError::InvalidTrackerId(id.clone()))
})
.collect::<Result<HashMap<_, _>, _>>()?;
Ok(Self {
bind_addr,
database_path: env
.database_path
.map(PathBuf::from)
.or(file.database_path)
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATABASE_PATH)),
media_root: env
.media_root
.map(PathBuf::from)
.or(file.media_root)
.unwrap_or_else(|| PathBuf::from(DEFAULT_MEDIA_ROOT)),
prowlarr_url: env
.prowlarr_url
.or(file.prowlarr_url)
.unwrap_or_else(|| DEFAULT_PROWLARR_URL.to_string()),
prowlarr_api_key: env.prowlarr_api_key,
qbittorrent_url: env
.qbittorrent_url
.or(file.qbittorrent_url)
.unwrap_or_else(|| DEFAULT_QBITTORRENT_URL.to_string()),
qbittorrent_username: env.qbittorrent_username,
qbittorrent_password: env.qbittorrent_password,
download_dir: env
.download_dir
.map(PathBuf::from)
.or(file.download_dir)
.unwrap_or_else(|| PathBuf::from(DEFAULT_DOWNLOAD_DIR)),
seed_ratio_limit: match &env.seed_ratio_limit {
Some(raw) => parse_number(raw, ENV_SEED_RATIO_LIMIT)?,
None => file.seed_ratio_limit.unwrap_or(DEFAULT_SEED_RATIO_LIMIT),
},
seed_idle_limit_minutes: match &env.seed_idle_limit_minutes {
Some(raw) => parse_number(raw, ENV_SEED_IDLE_LIMIT_MINUTES)?,
None => file
.seed_idle_limit_minutes
.unwrap_or(DEFAULT_SEED_IDLE_LIMIT_MINUTES),
},
tracker_seeding,
tmdb_api_key: env.tmdb_api_key,
tmdb_url: env.tmdb_url,
jellyfin_url: env
.jellyfin_url
.or(file.jellyfin_url)
.unwrap_or_else(|| DEFAULT_JELLYFIN_URL.to_string()),
jellyfin_api_key: env.jellyfin_api_key,
ntfy_url: env
.ntfy_url
.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: subtitles.opensubtitles_api_key,
opensubtitles_username: subtitles.opensubtitles_username,
opensubtitles_password: subtitles.opensubtitles_password,
translate_openai_api_key: subtitles.translate_openai_api_key,
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,
})
}
}
fn parse_number<T: std::str::FromStr>(raw: &str, env: &'static str) -> Result<T, ConfigError> {
raw.parse().map_err(|_| ConfigError::InvalidNumber {
env,
input: raw.to_string(),
})
}
fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
env,
input: raw.to_string(),
source,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
/// §10 keeps secrets out of the config file, so the `WebUI` login is
/// env-only and absent by default — which is the shape an instance that
/// bypasses authentication for arr's address wants.
#[test]
fn the_qbittorrent_login_is_env_only_and_optional() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
assert_eq!(config.qbittorrent_username, None);
assert_eq!(config.qbittorrent_password, None);
let config = Config::resolve(EnvOverrides {
qbittorrent_username: Some("arr".into()),
qbittorrent_password: Some("secret".into()),
..EnvOverrides::default()
})
.unwrap();
assert_eq!(config.qbittorrent_username.as_deref(), Some("arr"));
let error = toml::from_str::<ConfigFile>("qbittorrent_password = \"secret\"")
.expect_err("the file has no field for it");
assert!(error.to_string().contains("qbittorrent_password"));
}
#[test]
fn empty_environment_resolves_to_defaults() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
assert_eq!(config.bind_addr, DEFAULT_BIND_ADDR.parse().unwrap());
assert_eq!(config.database_path, PathBuf::from(DEFAULT_DATABASE_PATH));
assert_eq!(config.media_root, PathBuf::from(DEFAULT_MEDIA_ROOT));
assert_eq!(config.prowlarr_url, DEFAULT_PROWLARR_URL);
assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
assert_eq!(config.download_dir, PathBuf::from(DEFAULT_DOWNLOAD_DIR));
assert!((config.seed_ratio_limit - DEFAULT_SEED_RATIO_LIMIT).abs() < f64::EPSILON);
assert_eq!(
config.seed_idle_limit_minutes,
DEFAULT_SEED_IDLE_LIMIT_MINUTES
);
assert_eq!(config.jellyfin_url, DEFAULT_JELLYFIN_URL);
assert!(config.tracker_seeding.is_empty());
assert_eq!(config.ntfy_url, DEFAULT_NTFY_URL);
assert_eq!(config.ntfy_operator_topic, None);
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.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]
fn missing_config_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.bind_addr, DEFAULT_BIND_ADDR.parse().unwrap());
assert!(!path.exists(), "resolve must not create the file");
}
#[test]
fn config_file_overrides_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(
&path,
r#"
bind_addr = "127.0.0.1:1"
media_root = "/tank/media"
prowlarr_url = "http://prowlarr.internal:9696"
"#,
)
.unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.bind_addr, "127.0.0.1:1".parse().unwrap());
assert_eq!(config.media_root, PathBuf::from("/tank/media"));
assert_eq!(config.prowlarr_url, "http://prowlarr.internal:9696");
// Untouched fields still default.
assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
}
#[test]
fn env_overrides_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "bind_addr = \"127.0.0.1:1\"\n").unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
bind_addr: Some("0.0.0.0:9999".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.bind_addr, "0.0.0.0:9999".parse().unwrap());
}
#[test]
fn secrets_come_only_from_env() {
let env = EnvOverrides {
prowlarr_api_key: Some("secret-1".into()),
tmdb_api_key: Some("secret-2".into()),
jellyfin_api_key: Some("secret-3".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.prowlarr_api_key.as_deref(), Some("secret-1"));
assert_eq!(config.tmdb_api_key.as_deref(), Some("secret-2"));
assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3"));
}
/// §15: `OpenSubtitles` 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()),
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.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_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_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"));
}
/// #220 retired `translate_openai_base_url` and `translate_openai_model`
/// — both are `subtitle_settings` rows now. `deny_unknown_fields` turns
/// a config file still carrying them into a parse error, so an operator
/// who upgrades sees the move rather than a setting silently ignored.
#[test]
fn the_retired_openai_endpoint_keys_are_a_parse_error() {
for key in ["translate_openai_base_url", "translate_openai_model"] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, format!("{key} = \"x\"\n")).unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
assert!(
matches!(Config::resolve(env), Err(ConfigError::TomlDecode(_))),
"{key} must no longer be accepted"
);
}
}
#[test]
fn tmdb_url_is_an_env_only_seam() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
assert_eq!(config.tmdb_url, None);
let env = EnvOverrides {
tmdb_url: Some("http://127.0.0.1:9/3".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.tmdb_url.as_deref(), Some("http://127.0.0.1:9/3"));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "tmdb_url = \"http://127.0.0.1:9/3\"\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();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "prowlarr_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(_))
));
}
/// §7.3: both seeding limits are set at add time, so both have to be
/// configurable without a rebuild.
#[test]
fn seeding_limits_come_from_file_or_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(
&path,
"seed_ratio_limit = 2.5\nseed_idle_limit_minutes = 120\n",
)
.unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
let config = Config::resolve(env.clone()).unwrap();
assert!((config.seed_ratio_limit - 2.5).abs() < f64::EPSILON);
assert_eq!(config.seed_idle_limit_minutes, 120);
let config = Config::resolve(EnvOverrides {
seed_ratio_limit: Some("0.5".into()),
..env.clone()
})
.unwrap();
assert!((config.seed_ratio_limit - 0.5).abs() < f64::EPSILON);
let error = Config::resolve(EnvOverrides {
seed_idle_limit_minutes: Some("soon".into()),
..env
})
.unwrap_err();
assert!(matches!(
error,
ConfigError::InvalidNumber { env, .. } if env == ENV_SEED_IDLE_LIMIT_MINUTES
));
}
#[test]
fn tracker_seeding_rules_are_keyed_by_prowlarr_id() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(
&path,
"[tracker_seeding.3]\nratio = 2.5\nmin_seed_time = 120\n",
)
.unwrap();
let config = Config::resolve(EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
})
.unwrap();
assert_eq!(
config.tracker_seeding.get(&3),
Some(&TrackerSeedingRule {
ratio: 2.5,
min_seed_time: 120
})
);
}
#[test]
fn ntfy_operator_topic_comes_from_env_or_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "ntfy_operator_topic = \"from-file\"\n").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.ntfy_operator_topic.as_deref(), Some("from-file"));
let config = Config::resolve(EnvOverrides {
ntfy_operator_topic: Some("from-env".into()),
..env
})
.unwrap();
assert_eq!(config.ntfy_operator_topic.as_deref(), Some("from-env"));
}
#[test]
fn invalid_env_bind_addr_returns_diagnostic() {
let env = EnvOverrides {
bind_addr: Some("not-an-addr".into()),
..EnvOverrides::default()
};
let err = Config::resolve(env).unwrap_err();
match err {
ConfigError::InvalidBindAddr { env, input, .. } => {
assert_eq!(env, ENV_BIND_ADDR);
assert_eq!(input, "not-an-addr");
}
other => panic!("expected InvalidBindAddr, got {other:?}"),
}
}
}