Phase 3 of #18. Two tables: catalogue (each feed's config::Feed as JSON, so a new feed setting needs no column) and settings (general: the five server settings the admin page edits). config.toml keeps what is needed before the database is reached, or decides who gets in: paths, [torrent], [web]. ipx still runs from one in-memory Config, assembled at start from both (assemble_config). The eight places that saved config.toml and re-read it now call Ctx::store_cfg, which writes the database and swaps the copy in memory; the first-run web token, which is config.toml's, is written there. The first start on a database with no catalogue imports config.toml's feeds and settings in one transaction whose first insert is the settings row, so two ipx starting at once cannot both import; it then trims config.toml, keeping the original as config.toml.pre-database. After that, feeds written into the file are ignored with a warning. copy-db skips it, and copies both tables. Rehearsed on a clone of production's database with production's config: all 130 feeds imported, the file trimmed, and the feed list, settings and directory identical to the live server's. Postgres connections now ask for no notices. Every CREATE ... IF NOT EXISTS on an existing table sends one, eleven per open; sqlx logs them, and tracing-subscriber 0.3.23's per-layer filters then dropped the next line ipx logged -- the import's own message went missing that way. Proved by toggling it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
600 lines
22 KiB
Rust
600 lines
22 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>,
|
|
}
|
|
|
|
#[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>,
|
|
/// 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()],
|
|
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")
|
|
}
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
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()],
|
|
}
|
|
}
|
|
}
|
|
|
|
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>,
|
|
}
|
|
|
|
/// `[general]` keys that live in the database once it holds the configuration.
|
|
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 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,
|
|
});
|
|
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,
|
|
};
|
|
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"));
|
|
}
|
|
}
|