//! TOML configuration. Replaces iPXSettings.py and feeds.plist. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct Config { #[serde(default)] pub general: General, #[serde(default)] pub torrent: Torrent, #[serde(default)] pub web: Web, /// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL). #[serde(default)] pub feeds: BTreeMap, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct General { pub download_dir: PathBuf, pub socket: PathBuf, /// How often to re-check feeds: "every 30m", "every 4h", "90" (minutes), "1d". /// A feed's own `schedule` overrides this. pub schedule: String, /// Superseded by `schedule`. Still read so existing configs keep working. #[serde(skip_serializing_if = "Option::is_none")] pub interval_mins: Option, pub organize: Organize, /// 0 = unlimited. pub max_total_gb: f64, /// 0 = keep forever. pub max_age_days: u64, /// How many new enclosures a single scan may take, when a feed does not say. /// Unlimited by default was a trap: subscribing to an OPML of 80 feeds then pulled /// every back-catalogue episode at once. 0 means unlimited, deliberately chosen. pub max_new_per_check: usize, /// Top-level media types worth downloading. Blog feeds put each article's header /// image in an , so taking everything filled the disk with artwork and /// counted it as episodes. Empty means take anything. pub media_types: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum Organize { /// One folder per feed, named for the feed (or its `folder` override). Feed, /// One folder per day, MM-DD-YYYY, as iPXSettings.organizeDownloads == 1 did. Date, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Torrent { pub enabled: bool, /// Stop seeding at this ratio, or after seed_time_mins, whichever comes first. pub seed_ratio: f64, pub seed_time_mins: u64, pub port_range: String, /// Drop a torrent that has made no progress for this long. pub stall_mins: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Web { pub enabled: bool, /// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token. pub bind: String, /// Shared secret. Generated and written back on first run when left empty. It signs /// in as the admin, which is what keeps the healthcheck and any scripts working. pub token: String, /// A header naming the signed-in user, set by whatever fronts this -- Cloudflare Zero /// Trust sends `Cf-Access-Authenticated-User-Email`. Empty disables the whole path. pub trusted_header: String, /// Addresses allowed to assert that header. A header is only as trustworthy as the /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. pub trusted_proxies: Vec, /// Create an account the first time the proxy vouches for a name it has not seen. pub auto_create_users: bool, /// Sign a session out after this long without a request. pub session_days: i64, } impl Default for Web { fn default() -> Self { Self { enabled: false, bind: "127.0.0.1:8080".into(), token: String::new(), trusted_header: String::new(), trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], auto_create_users: true, session_days: 30, } } } impl Web { pub fn binds_publicly(&self) -> bool { !self.bind.starts_with("127.") && !self.bind.starts_with("localhost") } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Feed { pub url: String, /// Download folder name; defaults to the sanitized feed title. #[serde(skip_serializing_if = "Option::is_none")] pub folder: Option, /// Every whitespace-separated word of a keyword must appear in the /// url/title/description/categories for an enclosure to be taken. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub keywords: Vec, #[serde(default)] pub allow_explicit: bool, #[serde(default = "yes")] pub auto_download: bool, /// Set on feeds that came from a subscribed OPML: the id of the OPML feed they /// belong to. The OPML is re-read on every scan and this list kept in step. #[serde(default, skip_serializing_if = "Option::is_none")] pub group: Option, /// Overrides the global schedule for this feed. Same forms: "every 6h", "2d". #[serde(default, skip_serializing_if = "Option::is_none")] pub schedule: Option, /// Media types for this feed. None follows `[general]`; an empty list takes anything. #[serde(default, skip_serializing_if = "Option::is_none")] pub media_types: Option>, /// Cap on new downloads per scan for this feed. None follows `[general]`. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_new_per_check: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, /// Preferred over `password`: name of an env var holding the password. #[serde(default, skip_serializing_if = "Option::is_none")] pub password_env: Option, } fn yes() -> bool { true } impl Default for General { fn default() -> Self { Self { download_dir: home().join("Podcasts"), socket: default_socket(), schedule: "every 60m".into(), interval_mins: None, organize: Organize::Feed, max_total_gb: 0.0, max_age_days: 0, max_new_per_check: 3, media_types: vec!["audio".into(), "video".into()], } } } impl Default for Torrent { fn default() -> Self { Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30, } } } impl General { /// Minutes between checks. Falls back to the legacy `interval_mins`, then to an hour. /// A malformed value warns rather than stopping the daemon. pub fn interval(&self) -> u64 { if let Some(n) = parse_interval(&self.schedule) { return n; } if !self.schedule.trim().is_empty() { tracing::warn!(schedule = %self.schedule, "unrecognised schedule; using the default"); } self.interval_mins.filter(|n| *n > 0).unwrap_or(60) } } /// Parses a check interval into minutes. /// /// Accepts "every 30m", "30m", "4h", "1d", "2w", "every 4 hours", or a bare number of /// minutes. /// Returns None for anything it cannot read, or for zero. pub fn parse_interval(s: &str) -> Option { let s = s.trim().to_lowercase(); let s = s.strip_prefix("every").unwrap_or(&s).trim(); if s.is_empty() { return None; } let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); if digits.is_empty() { return None; } let n: u64 = digits.parse().ok()?; let unit = s[digits.len()..].trim(); let mins = match unit { "" | "m" | "min" | "mins" | "minute" | "minutes" => n, "h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?, "d" | "day" | "days" => n.checked_mul(1440)?, "w" | "week" | "weeks" => n.checked_mul(10080)?, _ => return None, }; (mins > 0).then_some(mins) } impl Torrent { /// Inclusive listen port range. Falls back to the BitTorrent default on garbage input. pub fn ports(&self) -> (u16, u16) { let parse = || -> Option<(u16, u16)> { let (lo, hi) = self.port_range.split_once('-')?; Some((lo.trim().parse().ok()?, hi.trim().parse().ok()?)) }; match parse() { Some((lo, hi)) if lo <= hi => (lo, hi), _ => (6881, 6889), } } } impl Feed { /// Resolved password: `password_env` wins over a literal `password`. pub fn password(&self) -> Option { if let Some(var) = &self.password_env { return std::env::var(var).ok(); } self.password.clone() } } impl Config { pub fn load(path: &Path) -> Result { if !path.exists() { // ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install. return Ok(Self::default()); } let text = std::fs::read_to_string(path) .with_context(|| format!("reading {}", path.display()))?; let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; cfg.general.download_dir = expand_tilde(&cfg.general.download_dir); Ok(cfg) } pub fn save(&self, path: &Path) -> Result<()> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .with_context(|| format!("creating {}", dir.display()))?; } let text = toml::to_string_pretty(self)?; std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; // Passwords may live in here. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } Ok(()) } } /// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`. pub fn config_path() -> PathBuf { if let Ok(p) = std::env::var("IPX_CONFIG") { return PathBuf::from(p); } dirs::config_dir() .unwrap_or_else(|| home().join(".config")) .join("ipx/config.toml") } /// `$IPX_DATA_DIR`, else `$XDG_DATA_HOME/ipx`. pub fn data_dir() -> PathBuf { if let Ok(p) = std::env::var("IPX_DATA_DIR") { return PathBuf::from(p); } dirs::data_dir() .unwrap_or_else(|| home().join(".local/share")) .join("ipx") } fn default_socket() -> PathBuf { match std::env::var_os("XDG_RUNTIME_DIR") { Some(dir) => PathBuf::from(dir).join("ipx.sock"), None => std::env::temp_dir().join("ipx.sock"), } } /// Whether an enclosure's type is one we want. /// /// An unknown type is allowed: the real type is only known after downloading, and /// refusing everything untyped would drop feeds that simply omit the attribute. pub fn wanted_media(mime: Option<&str>, wanted: &[String]) -> bool { if wanted.is_empty() { return true; } let Some(mime) = mime.map(str::trim).filter(|m| !m.is_empty()) else { return true; }; let top = mime.split('/').next().unwrap_or(mime).to_ascii_lowercase(); // A .torrent is a container for media, not media itself; judge it once unpacked. if mime.to_ascii_lowercase().contains("torrent") { return true; } wanted.iter().any(|w| w.trim().eq_ignore_ascii_case(&top) || w.trim().eq_ignore_ascii_case(mime)) } /// Feed ids are the TOML table key, so they must be readable and punctuation-free. pub fn slug(text: &str) -> String { let mut out = String::new(); for c in text.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } else if c.is_alphanumeric() { out.push(c); // Keep non-ASCII letters; TOML bare keys are stricter, but we quote. } else if !out.ends_with('-') { out.push('-'); } } let out = out.trim_matches('-').to_owned(); let out: String = out.chars().take(40).collect(); let out = out.trim_matches('-').to_owned(); if out.is_empty() { "feed".into() } else { out } } /// `slug`, with a numeric suffix if that id is taken. pub fn unique_slug(text: &str, taken: &BTreeMap) -> String { let base = slug(text); if !taken.contains_key(&base) { return base; } (2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap() } fn home() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } fn expand_tilde(p: &Path) -> PathBuf { match p.strip_prefix("~") { Ok(rest) => home().join(rest), Err(_) => p.to_path_buf(), } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_a_config_and_applies_defaults() { let cfg: Config = toml::from_str( r#" [general] download_dir = "/tmp/pods" [feeds.example] url = "https://example.com/feed.xml" keywords = ["deep dive"] "#, ) .unwrap(); assert_eq!(cfg.general.download_dir, PathBuf::from("/tmp/pods")); assert_eq!(cfg.general.interval(), 60); assert_eq!(cfg.general.organize, Organize::Feed); assert!(cfg.torrent.enabled); let feed = &cfg.feeds["example"]; assert!(feed.auto_download, "auto_download defaults on"); assert!(!feed.allow_explicit); assert_eq!(feed.max_new_per_check, None); assert_eq!(feed.keywords, vec!["deep dive"]); } #[test] fn intervals_parse_from_the_forms_people_actually_type() { for (input, want) in [ ("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30), ("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240), ("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90), ("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160), ] { assert_eq!(parse_interval(input), Some(want), "{input:?}"); } for bad in ["", " ", "every", "soon", "-5m", "0", "0h", "every 0 minutes", "5 fortnights"] { assert_eq!(parse_interval(bad), None, "{bad:?} should not parse"); } } #[test] fn interval_falls_back_through_legacy_then_default() { let mut g = General::default(); assert_eq!(g.interval(), 60, "the default schedule"); g.schedule = "every 15m".into(); assert_eq!(g.interval(), 15); // A config written before `schedule` existed still works. g.schedule = String::new(); g.interval_mins = Some(45); assert_eq!(g.interval(), 45); // Garbage must not stop the daemon. g.schedule = "whenever".into(); assert_eq!(g.interval(), 45); g.interval_mins = None; assert_eq!(g.interval(), 60); } #[test] fn port_range_falls_back_when_malformed() { let mut t = Torrent::default(); assert_eq!(t.ports(), (6881, 6889)); t.port_range = "51413-51420".into(); assert_eq!(t.ports(), (51413, 51420)); t.port_range = "nonsense".into(); assert_eq!(t.ports(), (6881, 6889)); t.port_range = "900-100".into(); assert_eq!(t.ports(), (6881, 6889), "reversed range is not a range"); } #[test] fn media_types_keep_article_artwork_out() { let want = vec!["audio".to_string(), "video".to_string()]; assert!(wanted_media(Some("audio/mpeg"), &want)); assert!(wanted_media(Some("audio/mp4"), &want)); assert!(wanted_media(Some("video/quicktime"), &want)); assert!(!wanted_media(Some("image/jpeg"), &want), "a blog header image is not an episode"); assert!(!wanted_media(Some("text/html"), &want)); // A torrent is a container; what is inside is judged after unpacking. assert!(wanted_media(Some("application/x-bittorrent"), &want)); // Unknown type: only discoverable by downloading, so do not refuse it outright. assert!(wanted_media(None, &want)); assert!(wanted_media(Some(""), &want)); // An empty list means take anything, which is how it behaved before. assert!(wanted_media(Some("image/jpeg"), &[])); // A full type can be named exactly. assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()])); } #[test] fn slugs_are_readable_and_unique() { assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast"); assert_eq!(slug(" The Daily!! "), "the-daily"); assert_eq!(slug("99% Invisible"), "99-invisible"); assert_eq!(slug("///"), "feed"); assert_eq!(slug("").len(), 4); assert!(slug(&"x".repeat(100)).len() <= 40); let mut taken = BTreeMap::new(); taken.insert("the-daily".to_string(), Feed { url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, max_new_per_check: None, username: None, password: None, password_env: None, }); assert_eq!(unique_slug("The Daily", &taken), "the-daily-2"); } #[test] fn password_env_wins_over_literal() { let mut f = Feed { url: "https://x/y".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, max_new_per_check: None, username: Some("ray".into()), password: Some("literal".into()), password_env: None, }; assert_eq!(f.password().as_deref(), Some("literal")); unsafe { std::env::set_var("IPX_TEST_PASS", "from-env") }; f.password_env = Some("IPX_TEST_PASS".into()); assert_eq!(f.password().as_deref(), Some("from-env")); } }