axum served from inside the daemon so it reads SQLite and the event bus directly: browse feeds, read show notes, play with seeking, download and delete files, mark read/flag, and edit feed settings. Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI edits apply without a daemon restart. Access is a shared token minted from /dev/urandom, carried in a cookie because an <audio> element cannot send headers. Show notes are untrusted feed HTML and are sanitized with ammonia server-side. read/flagged finally have a writer, which retention has needed since it started ordering by them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
337 lines
10 KiB
Rust
337 lines
10 KiB
Rust
//! 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<String, Feed>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(default)]
|
|
pub struct General {
|
|
pub download_dir: PathBuf,
|
|
pub socket: PathBuf,
|
|
/// Default poll interval; a feed's own <ttl> wins when it is longer.
|
|
pub interval_mins: u64,
|
|
pub organize: Organize,
|
|
/// 0 = unlimited.
|
|
pub max_total_gb: f64,
|
|
/// 0 = keep forever.
|
|
pub max_age_days: u64,
|
|
}
|
|
|
|
#[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.
|
|
pub token: String,
|
|
}
|
|
|
|
impl Default for Web {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
bind: "127.0.0.1:8080".into(),
|
|
token: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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<String>,
|
|
/// 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<String>,
|
|
#[serde(default)]
|
|
pub allow_explicit: bool,
|
|
#[serde(default = "yes")]
|
|
pub auto_download: bool,
|
|
/// Cap on new downloads per scan. None = unlimited.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_new_per_check: Option<usize>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub password: Option<String>,
|
|
/// Preferred over `password`: name of an env var holding the password.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub password_env: Option<String>,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Default for General {
|
|
fn default() -> Self {
|
|
Self {
|
|
download_dir: home().join("Podcasts"),
|
|
socket: default_socket(),
|
|
interval_mins: 60,
|
|
organize: Organize::Feed,
|
|
max_total_gb: 0.0,
|
|
max_age_days: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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<String> {
|
|
if let Some(var) = &self.password_env {
|
|
return std::env::var(var).ok();
|
|
}
|
|
self.password.clone()
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load(path: &Path) -> Result<Self> {
|
|
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"),
|
|
}
|
|
}
|
|
|
|
/// 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, Feed>) -> 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_mins, 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 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 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, 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,
|
|
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"));
|
|
}
|
|
}
|