The page loaded artwork from each publisher's server, or through /api/art, fetched every time, for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when a publisher's server did. - The page draws every image a feed or item names from /api/art. The first time, iPX fetches it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout) and keeps it in art/ beside the database, under a hash of its address with its type beside it (src/art.rs). Later it comes from disk, which marks it as used. - art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and artwork is fetched through iPX each time. Settings saved before it get the default. - Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP with sandbox, so an SVG opened on its own runs no script as iPX. - The fixture server sends .jpg as image/jpeg, which /api/art requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
24 KiB
Rust
641 lines
24 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,
|
|
/// How often to re-check feeds: "every 30m", "every 4h", "90" (minutes), "1d".
|
|
/// A feed's own `schedule` overrides this.
|
|
pub schedule: String,
|
|
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 <enclosure>, so taking everything filled the disk with artwork and
|
|
/// counted it as episodes. Empty means take anything.
|
|
pub media_types: Vec<String>,
|
|
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
|
/// every publisher; the least recently shown goes first. 0 keeps none.
|
|
pub art_cache_mb: 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. 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<String>,
|
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
|
/// host any container can send one from the gateway's address.
|
|
pub access_team: String,
|
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
|
/// leaves the signature unchecked.
|
|
pub access_aud: String,
|
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
|
pub auto_create_users: bool,
|
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
|
/// while the proxy still vouches for them, so this is the proxy's own sign-out:
|
|
/// `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to /login.
|
|
pub sign_out_url: String,
|
|
/// 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()],
|
|
access_team: String::new(),
|
|
access_aud: String::new(),
|
|
auto_create_users: true,
|
|
sign_out_url: String::new(),
|
|
session_days: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Web {
|
|
pub fn binds_publicly(&self) -> bool {
|
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
|
}
|
|
|
|
/// Both halves of the Access check, or None while either is unset.
|
|
pub fn access(&self) -> Option<(&str, &str)> {
|
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
|
}
|
|
}
|
|
|
|
#[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>,
|
|
/// The Directory's category for a feed that names none of its own, as most blogs do not.
|
|
/// The feed's own iTunes category wins where there is one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub category: 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,
|
|
/// 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<String>,
|
|
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub schedule: Option<String>,
|
|
/// 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<Vec<String>>,
|
|
/// 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<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>,
|
|
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
|
|
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub listed: bool,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Default for General {
|
|
fn default() -> Self {
|
|
Self {
|
|
download_dir: home().join("Podcasts"),
|
|
socket: default_socket(),
|
|
schedule: "every 60m".into(),
|
|
organize: Organize::Feed,
|
|
max_total_gb: 0.0,
|
|
max_age_days: 0,
|
|
max_new_per_check: 3,
|
|
media_types: vec!["audio".into(), "video".into()],
|
|
art_cache_mb: 500,
|
|
}
|
|
}
|
|
}
|
|
|
|
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, or an hour when `schedule` is empty or unreadable. 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");
|
|
}
|
|
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<u64> {
|
|
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<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)
|
|
}
|
|
|
|
}
|
|
|
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
|
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
|
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
|
/// or decides who gets in, and stays in config.toml.
|
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
|
pub struct Stored {
|
|
pub schedule: String,
|
|
pub max_total_gb: f64,
|
|
pub max_age_days: u64,
|
|
pub max_new_per_check: usize,
|
|
pub media_types: Vec<String>,
|
|
/// Settings saved before it existed have none: they get the default.
|
|
#[serde(default = "art_cache_mb")]
|
|
pub art_cache_mb: u64,
|
|
}
|
|
|
|
fn art_cache_mb() -> u64 {
|
|
General::default().art_cache_mb
|
|
}
|
|
|
|
/// `[general]` keys that live in the database once it holds the configuration.
|
|
const STORED_KEYS: [&str; 6] =
|
|
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
|
|
|
|
impl Stored {
|
|
pub fn of(cfg: &Config) -> Self {
|
|
let g = &cfg.general;
|
|
Self {
|
|
schedule: g.schedule.clone(),
|
|
max_total_gb: g.max_total_gb,
|
|
max_age_days: g.max_age_days,
|
|
max_new_per_check: g.max_new_per_check,
|
|
media_types: g.media_types.clone(),
|
|
art_cache_mb: g.art_cache_mb,
|
|
}
|
|
}
|
|
|
|
pub fn apply(self, cfg: &mut Config) {
|
|
let g = &mut cfg.general;
|
|
g.schedule = self.schedule;
|
|
g.max_total_gb = self.max_total_gb;
|
|
g.max_age_days = self.max_age_days;
|
|
g.max_new_per_check = self.max_new_per_check;
|
|
g.media_types = self.media_types;
|
|
g.art_cache_mb = self.art_cache_mb;
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
|
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
|
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
|
let mut v = toml::Value::try_from(self)?;
|
|
if let Some(t) = v.as_table_mut() {
|
|
t.remove("feeds");
|
|
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
|
for k in STORED_KEYS {
|
|
g.remove(k);
|
|
}
|
|
}
|
|
}
|
|
write_private(path, &toml::to_string_pretty(&v)?)
|
|
}
|
|
|
|
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
|
/// an edit there would otherwise go unnoticed.
|
|
pub fn file_holds_stored(path: &Path) -> bool {
|
|
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
|
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
|
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
|
|| v.get("general")
|
|
.and_then(|g| g.as_table())
|
|
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
|
}
|
|
}
|
|
|
|
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
|
fn write_private(path: &Path, text: &str) -> Result<()> {
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
|
}
|
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
|
#[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);
|
|
}
|
|
xdg("XDG_CONFIG_HOME", ".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);
|
|
}
|
|
xdg("XDG_DATA_HOME", ".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, 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()
|
|
}
|
|
|
|
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
|
|
fn xdg(var: &str, fallback: &str) -> PathBuf {
|
|
std::env::var_os(var)
|
|
.filter(|v| !v.is_empty())
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| home().join(fallback))
|
|
}
|
|
|
|
fn home() -> PathBuf {
|
|
std::env::var_os("HOME").map(PathBuf::from).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 the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
|
let cfg: Config = toml::from_str(
|
|
r#"
|
|
[general]
|
|
download_dir = "/downloads"
|
|
schedule = "every 2h"
|
|
max_new_per_check = 7
|
|
media_types = ["audio"]
|
|
[web]
|
|
bind = "0.0.0.0:8099"
|
|
token = "t"
|
|
[feeds.show]
|
|
url = "http://x/show.xml"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
|
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
|
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
|
cfg.save_bootstrap(&path).unwrap();
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
assert!(!Config::file_holds_stored(&path), "{text}");
|
|
let back: Config = toml::from_str(&text).unwrap();
|
|
assert!(back.feeds.is_empty());
|
|
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
|
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
|
std::fs::remove_file(&path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parses_a_config_and_applies_defaults() {
|
|
let cfg: Config = toml::from_str(
|
|
r#"
|
|
[general]
|
|
download_dir = "/tmp/pods"
|
|
# A key older versions read. An old config that still has it has to load.
|
|
interval_mins = 45
|
|
|
|
[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_to_an_hour() {
|
|
let mut g = General::default();
|
|
assert_eq!(g.interval(), 60, "the default schedule");
|
|
|
|
g.schedule = "every 15m".into();
|
|
assert_eq!(g.interval(), 15);
|
|
|
|
// Empty or garbage must not stop the daemon.
|
|
g.schedule = String::new();
|
|
assert_eq!(g.interval(), 60);
|
|
g.schedule = "whenever".into();
|
|
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 settings_saved_before_the_art_cache_keep_the_default() {
|
|
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
|
|
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
|
|
}
|
|
|
|
#[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, category: None, listed: false,
|
|
});
|
|
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,
|
|
category: None,
|
|
listed: false,
|
|
};
|
|
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"));
|
|
}
|
|
}
|