mod config; mod db; mod download; mod feed; mod ipc; mod logbuf; mod retention; mod torrent; mod web; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use ipc::{Command as Cmd, Emitter, Event}; use std::path::PathBuf; use std::sync::Arc; use std::future::Future; use tokio::sync::{broadcast, mpsc, watch}; #[derive(Parser)] #[command(name = "ipx", version, about = "A headless podcatcher")] struct Cli { /// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml) #[arg(long, global = true)] config: Option, /// Do the work here even if a daemon is running #[arg(long, global = true)] local: bool, #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Show configured feeds and their state List, /// Scan feeds for new entries Fetch { /// Only this feed id feed: Option, /// Poll even when the feed is not due yet #[arg(long)] force: bool, }, /// Delete old or over-quota downloads Reap { /// Show what would go, delete nothing #[arg(long)] dry_run: bool, }, /// Counts of feeds, pending and downloaded enclosures Status, /// Subscribe to a feed Add { url: String, /// Download folder name (default: the feed title) #[arg(long)] folder: Option, /// Only take enclosures matching these keywords #[arg(long, value_delimiter = ',')] keywords: Vec, }, /// Unsubscribe. Downloads and history are left alone. Rm { feed: String }, /// Add every feed in an OPML file Import { file: PathBuf }, /// Write subscriptions out as OPML Export { file: PathBuf }, /// Run the scheduler and serve the control socket Daemon { /// Serve the web UI on this address, overriding [web] in the config #[arg(long, value_name = "ADDR")] web: Option, }, } /// Everything a command needs. One per process. pub struct Ctx { /// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks /// up feed changes without a restart. Callers take a snapshot; no guard is ever held /// across an await. pub cfg: std::sync::RwLock>, pub db: db::Db, pub client: reqwest::Client, pub out: Emitter, /// Started on first use: a BitTorrent session binds ports and starts a DHT, which is /// rude to do for a config that has never seen a torrent. pub torrents: tokio::sync::OnceCell, /// Caps how many torrents run at once when they are detached. pub torrent_slots: Arc, /// Needed because a subscribed OPML rewrites the feed list as it syncs. pub config_path: PathBuf, /// Run torrents off the command worker. A torrent takes minutes to fetch metadata and /// then seeds for up to an hour, and the worker is sequential -- inline, one torrent /// stops every feed scan, every HTTP download and every status command behind it. /// A one-shot CLI run keeps them inline, or the process would exit mid-download. pub detach_torrents: bool, } impl Ctx { pub fn cfg(&self) -> std::sync::Arc { self.cfg.read().unwrap().clone() } /// Re-reads config.toml into the live snapshot. pub fn reload_cfg(&self, path: &std::path::Path) -> Result<()> { let fresh = config::Config::load(path)?; *self.cfg.write().unwrap() = std::sync::Arc::new(fresh); tracing::info!("config reloaded"); Ok(()) } async fn torrents(&self) -> Result<&torrent::Torrents> { let cfg = self.cfg(); self.torrents .get_or_try_init(|| torrent::Torrents::new(&cfg)) .await } } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); // Everything goes to stderr as before, and is mirrored into a ring the UI can read. { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::Layer; // Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer // keeps debug as well, so the log view can show protocol traffic and routine // skips that would be noise on a terminal. IPX_UI_LOG overrides it. let stderr_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_LOG") .unwrap_or_else(|_| "ipx=info".into()); let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG") .unwrap_or_else(|_| "ipx=debug".into()); tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) .with_filter(stderr_filter), ) .with(logbuf::RingLayer.with_filter(ui_filter)) .init(); } let config_path = cli.config.clone().unwrap_or_else(config::config_path); let cfg = config::Config::load(&config_path)?; let db = db::Db::open(&config::data_dir().join("state.db"))?; // A daemon owns the state; don't have two processes downloading the same thing. let wire_cmd = match &cli.command { Command::Fetch { feed, force } => { Some(Cmd::Fetch { feed: feed.clone(), force: *force }) } Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), Command::Status => Some(Cmd::Status), Command::List | Command::Daemon { .. } | Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::Export { .. } => None, }; if let Some(cmd) = &wire_cmd && !cli.local && ipc::daemon_is_live(&cfg.general.socket).await { return ipc::proxy(&cfg.general.socket, cmd).await; } let is_daemon = matches!(cli.command, Command::Daemon { .. }); let (events, _) = broadcast::channel(1024); let ctx = Arc::new(Ctx { cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)), db, client: reqwest::Client::builder() .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .build()?, out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() }, torrents: tokio::sync::OnceCell::new(), torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)), config_path: config_path.clone(), detach_torrents: is_daemon, }); match cli.command { Command::List => list(&ctx, &config_path), Command::Daemon { web } => daemon(ctx, config_path, web, events).await, Command::Add { url, folder, keywords } => { add(&ctx, &config_path, &url, folder, keywords).await } Command::Rm { feed } => rm(&ctx, &config_path, &feed), Command::Import { file } => import(&ctx, &config_path, &file).await, Command::Export { file } => export(&ctx, &file), _ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, } } async fn run(ctx: &Arc, cmd: Cmd) -> Result<()> { match cmd { Cmd::Fetch { feed, force } => { // Make room before pulling more down, as the original did per download. reap(ctx, false, false)?; fetch(ctx, feed.as_deref(), force).await } Cmd::Reap { dry_run } => reap(ctx, dry_run, true), Cmd::Download { enclosure } => download_one(ctx, enclosure).await, Cmd::Status => { let (pending, downloaded) = ctx.db.counts()?; let feeds = subscriptions(ctx).map(|s| s.len()).unwrap_or(0); ctx.out.emit(Event::Status { feeds, pending, downloaded }); Ok(()) } } } async fn daemon( ctx: Arc, config_path: PathBuf, web_addr: Option, events: broadcast::Sender, ) -> Result<()> { let socket = ctx.cfg().general.socket.clone(); if ipc::daemon_is_live(&socket).await { anyhow::bail!("a daemon is already listening on {}", socket.display()); } match migrate_opml_children(&ctx) { Ok(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"), Ok(_) => {} Err(e) => tracing::warn!(error = ?e, "could not tidy OPML feeds out of the config"), } match ctx.db.requeue_interrupted() { Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"), Ok(_) => {} Err(e) => tracing::warn!(error = ?e, "could not requeue interrupted downloads"), } let (tx_cmd, mut rx_cmd) = mpsc::channel::(64); let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?; let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd)); // One command at a time: the queue is what keeps two scans from overlapping. let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); tracing::info!( feeds = subscriptions(&ctx).map(|s| s.len()).unwrap_or(0), "daemon started" ); // A signal has to be able to interrupt work in progress, not just the wait between // jobs. Racing `shutdown()` in the outer select only cancels branch selection: once // inside a long download the daemon stopped listening and had to be SIGKILLed. let (tx_stop, rx_stop) = tokio::sync::watch::channel(false); tokio::spawn(async move { shutdown().await; let _ = tx_stop.send(true); }); // Runs one job, abandoning it if a signal arrives. Returns false to end the loop. async fn until_stopped( ctx: &Ctx, rx: &watch::Receiver, job: impl Future>, ) -> bool { let mut stop = rx.clone(); tokio::select! { _ = stop.changed() => { tracing::info!("signal received; abandoning the job in progress"); false } result = job => { if let Err(e) = result { ctx.out.emit(Event::Error { msg: format!("{e:#}") }); } true } } } let mut stop = rx_stop.clone(); loop { if *stop.borrow() { break; } tokio::select! { biased; _ = stop.changed() => break, Some(cmd) = rx_cmd.recv() => { // Both halves of the protocol are logged under one target so the UI can // show the conversation on its own: this is everything arriving, whatever // the source -- a socket client, the CLI proxying, or the web UI. tracing::info!( target: "ipx::io", "-> {}", serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}")) ); if !until_stopped(&ctx, &rx_stop, run(&ctx, cmd)).await { break; } } _ = ticker.tick() => { // Per-feed schedule and TTL decide what actually gets polled. let job = run(&ctx, Cmd::Fetch { feed: None, force: false }); if !until_stopped(&ctx, &rx_stop, job).await { break; } } } } server.abort(); if let Some(w) = web { w.abort(); } let _ = std::fs::remove_file(&socket); tracing::info!("daemon stopped"); Ok(()) } /// Starts the web UI when it is switched on, minting and saving a token if there is none. async fn start_web( ctx: &Arc, config_path: &std::path::Path, web_addr: Option, cmds: &mpsc::Sender, events: &broadcast::Sender, ) -> Result>> { let cfg = ctx.cfg(); let enabled = cfg.web.enabled || web_addr.is_some(); if !enabled { return Ok(None); } let bind = web_addr.unwrap_or_else(|| cfg.web.bind.clone()); if cfg.web.token.is_empty() { let mut fresh = (*cfg).clone(); fresh.web.enabled = true; fresh.web.bind = bind.clone(); fresh.web.token = web::generate_token(); fresh.save(config_path)?; ctx.reload_cfg(config_path)?; println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token); } else { println!( "web ui at http://{bind}/?token={}", ctx.cfg().web.token ); } if ctx.cfg().web.binds_publicly() { tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); } let state = web::WebState { ctx: ctx.clone(), config_path: config_path.to_path_buf(), cmds: cmds.clone(), events: events.clone(), }; Ok(Some(tokio::spawn(async move { if let Err(e) = web::serve(state, &bind).await { tracing::error!(error = ?e, "web ui stopped"); } }))) } async fn shutdown() { use tokio::signal::unix::{SignalKind, signal}; let mut term = match signal(SignalKind::terminate()) { Ok(s) => s, Err(_) => return std::future::pending().await, }; tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = term.recv() => {} } } /// Subscribes to one feed, naming it from its own title. async fn add( ctx: &Ctx, config_path: &std::path::Path, url: &str, folder: Option, keywords: Vec, ) -> Result<()> { let mut cfg = (*ctx.cfg()).clone(); // Includes feeds derived from an OPML, or the same show could be added twice. if let Some(existing) = subscriptions(ctx)?.iter().find(|s| s.cfg.url == url) { anyhow::bail!("already subscribed as {:?}", existing.id); } let id = add_one(ctx, &mut cfg, url, folder, keywords).await?; cfg.save(config_path)?; println!("added {id}"); Ok(()) } /// Returns the new feed id. The title needs a fetch, so a feed that cannot be reached is /// still added -- under a slug derived from its URL -- rather than refused. pub async fn add_one( ctx: &Ctx, cfg: &mut config::Config, url: &str, folder: Option, keywords: Vec, ) -> Result { let probe = config::Feed { url: url.to_owned(), folder: folder.clone(), group: None, media_types: None, schedule: None, keywords: keywords.clone(), allow_explicit: false, auto_download: true, max_new_per_check: None, username: None, password: None, password_env: None, }; let title = match feed::fetch(&ctx.client, &probe, None, None).await { // An OPML subscription is named from its own , not by trying to // parse it as a feed and falling back to the hostname. Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => { feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url)) } Ok(feed::Fetched::Body { bytes, .. }) => feed::parse(&bytes) .ok() .and_then(|f| f.title) .unwrap_or_else(|| url_stem(url)), _ => { tracing::warn!(url, "could not read the feed; naming it from its URL"); url_stem(url) } }; // Slugs must be unique across derived feeds too, or a new feed can collide with one // an OPML already introduced. let taken: std::collections::BTreeMap<String, config::Feed> = subscriptions(ctx)? .into_iter() .map(|s| (s.id, s.cfg)) .collect(); let id = config::unique_slug(&title, &taken); cfg.feeds.insert(id.clone(), probe); Ok(id) } /// Host plus last path segment, for naming a feed we could not read. fn url_stem(url: &str) -> String { url::Url::parse(url) .ok() .and_then(|u| u.host_str().map(str::to_owned)) .unwrap_or_else(|| url.to_owned()) } fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> { let mut cfg = (*ctx.cfg()).clone(); if cfg.feeds.remove(feed).is_none() { // Derived from an OPML: drop it here, though the subscription will list it again // on the next read unless the OPML itself goes. ctx.db.drop_managed(feed)?; println!("removed {feed}; it came from an OPML subscription and may return on the next read"); return Ok(()); } cfg.save(config_path)?; // State and files stay: re-adding the feed should not re-download its back catalogue. println!("removed {feed}; downloads and history kept"); Ok(()) } async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> { let mut cfg = (*ctx.cfg()).clone(); let text = std::fs::read_to_string(file) .with_context(|| format!("reading {}", file.display()))?; let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("parsing OPML: {e}"))?; let mut found = vec![]; collect_outlines(&doc.body.outlines, &mut found); let mut added = 0; for (title, url) in found { if cfg.feeds.values().any(|f| f.url == url) { continue; } // Name it from the OPML title rather than refetching every feed. let id = config::unique_slug(&title, &cfg.feeds); cfg.feeds.insert( id.clone(), config::Feed { url, 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, }, ); println!("added {id}"); added += 1; } cfg.save(config_path)?; println!("{added} feed(s) imported"); Ok(()) } /// OPML nests feeds inside folder outlines, so this walks the whole tree. pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) { for o in outlines { if let Some(url) = &o.xml_url { let title = o.title.clone().unwrap_or_else(|| o.text.clone()); out.push((title, url.clone())); } collect_outlines(&o.outlines, out); } } fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> { let mut doc = opml::OPML::default(); doc.head = Some(opml::Head { title: Some("ipx subscriptions".into()), ..Default::default() }); for (id, feed) in &ctx.cfg().feeds { let title = ctx .db .feed_summary(id) .ok() .and_then(|s| s.title) .unwrap_or_else(|| id.clone()); doc.add_feed(&title, &feed.url); } let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?; std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?; println!("exported {} feed(s) to {}", ctx.cfg().feeds.len(), file.display()); Ok(()) } fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> { let cfg = ctx.cfg(); if cfg.feeds.is_empty() { println!("No feeds configured in {}", config_path.display()); return Ok(()); } for (id, feed) in &cfg.feeds { let s = ctx.db.feed_summary(id)?; println!("{id} {}", s.title.as_deref().unwrap_or("-")); println!(" url {}", feed.url); println!(" last checked {}", ago(s.last_checked)); println!(" entries {} ({} downloaded)", s.entries, s.downloaded); if let Some(err) = &s.last_error { println!(" last error {err}"); } } Ok(()) } /// `standalone` false means this is the sweep that runs before a scan: it reports what it /// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch` /// would stop reading before the scan had even started. fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> { let r = retention::run(&ctx.cfg(), &ctx.db, dry_run)?; for c in r.aged_out.iter().chain(r.over_quota.iter()) { ctx.out.emit(Event::Reaped { path: c.path.clone(), bytes: c.bytes.max(0) as u64, }); } if standalone { ctx.out.emit(Event::ReapDone { files: r.aged_out.len() + r.over_quota.len(), bytes: r.bytes_freed, }); } Ok(()) } async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> { let cfg = ctx.cfg(); let subs = subscriptions(ctx)?; if let Some(id) = only && !subs.iter().any(|s| s.id == id) { anyhow::bail!("no feed with id {id:?}"); } let mut scanned = 0; let mut fresh: Vec<String> = vec![]; for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { let (id, feed_cfg) = (&sub.id, &sub.cfg); let state = ctx.db.http_state(id)?; if !force && let Some(last) = state.last_checked { let due = last + due_after(&cfg, feed_cfg, state.ttl_mins) as i64; if due > db::now() { ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: format!("not due for {}", duration((due - db::now()) as u64)), }); continue; } } scanned += 1; ctx.out.emit(Event::FeedStart { feed: id.clone() }); match scan_one(ctx, id, feed_cfg, &state).await { Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone { feed: id.clone(), new: s.new_entries, downloaded: s.downloaded, failed: s.failed, torrents: s.torrents, }), Ok(Outcome::NotModified) => ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: "not modified".into(), }), Ok(Outcome::Opml { added, removed, kept, total }) => { ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: format!( "OPML: {total} feed(s) listed, {} added, {removed} unsubscribed, {kept} kept without a listing", added.len() ), }); // Read them in the same pass, as the original did, rather than making // the user wait a whole interval for a newly listed show. fresh.extend(added); } Err(e) => { // One bad feed must not end the scan. let msg = format!("{e:#}"); ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() }); ctx.db.set_feed_error(id, &feed_cfg.url, &msg)?; } } } // Feeds a subscribed OPML just introduced: scan them now, in this run. if !fresh.is_empty() { let subs = subscriptions(ctx)?; for id in &fresh { let Some(feed_cfg) = subs.iter().find(|s| &s.id == id).map(|s| &s.cfg) else { continue; }; scanned += 1; ctx.out.emit(Event::FeedStart { feed: id.clone() }); let state = ctx.db.http_state(id)?; match scan_one(ctx, id, feed_cfg, &state).await { Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone { feed: id.clone(), new: s.new_entries, downloaded: s.downloaded, failed: s.failed, torrents: s.torrents, }), Ok(_) => {} Err(e) => { let msg = format!("{e:#}"); ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() }); ctx.db.set_feed_error(id, &feed_cfg.url, &msg)?; } } } } ctx.out.emit(Event::ScanDone { feeds: scanned }); Ok(()) } /// One feed to scan: either an entry you wrote in config.toml, or one derived from an /// OPML subscription and held only in the database. pub struct Sub { pub id: String, pub cfg: config::Feed, /// True when it came from an OPML and has no config entry of its own. pub managed: bool, } /// Everything to scan: your config entries, plus whatever the OPML subscriptions listed. /// /// A derived feed borrows its parent's settings wholesale. That is why it needs no config /// entry -- there is nothing to store but its URL and where it came from. pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> { let cfg = ctx.cfg(); let mut out: Vec<Sub> = cfg .feeds .iter() .map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }) .collect(); for m in ctx.db.managed_feeds()? { if cfg.feeds.contains_key(&m.id) { continue; // promoted to config at some point; that entry wins } let parent = cfg.feeds.get(&m.group_id); let base = parent .and_then(|p| p.folder.clone()) .or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title)) .unwrap_or_else(|| m.group_id.clone()); let title = m.title.clone().unwrap_or_else(|| m.id.clone()); out.push(Sub { id: m.id.clone(), cfg: config::Feed { url: m.url.clone(), folder: Some(format!("{base}/{title}")), group: Some(m.group_id.clone()), media_types: parent.and_then(|p| p.media_types.clone()), schedule: parent.and_then(|p| p.schedule.clone()), keywords: parent.map(|p| p.keywords.clone()).unwrap_or_default(), allow_explicit: parent.is_some_and(|p| p.allow_explicit), auto_download: parent.is_none_or(|p| p.auto_download), max_new_per_check: parent.and_then(|p| p.max_new_per_check), username: parent.and_then(|p| p.username.clone()), password: parent.and_then(|p| p.password.clone()), password_env: parent.and_then(|p| p.password_env.clone()), }, managed: true, }); } out.sort_by(|a, b| a.id.cmp(&b.id)); Ok(out) } /// Moves OPML children that older versions wrote into config.toml over to the database. /// They were never yours to edit, and 80-odd of them made the file unreadable. fn migrate_opml_children(ctx: &Ctx) -> Result<usize> { let cfg = (*ctx.cfg()).clone(); let children: Vec<(String, config::Feed)> = cfg .feeds .iter() .filter(|(_, f)| f.group.is_some()) .map(|(id, f)| (id.clone(), f.clone())) .collect(); if children.is_empty() { return Ok(0); } let mut fresh = cfg.clone(); for (id, f) in &children { let group = f.group.clone().unwrap_or_default(); let title = ctx .db .feed_summary(id) .ok() .and_then(|s| s.title) .unwrap_or_else(|| id.clone()); ctx.db.upsert_managed(id, &f.url, &title, &group)?; fresh.feeds.remove(id); } fresh.save(&ctx.config_path)?; ctx.reload_cfg(&ctx.config_path)?; Ok(children.len()) } /// Seconds to wait before re-checking a feed. /// /// A per-feed schedule is an explicit instruction and wins outright. Without one, the /// global schedule applies, but the feed's own <ttl> raises it when the publisher asks to /// be polled less often. pub fn due_after(cfg: &config::Config, feed: &config::Feed, ttl_mins: Option<u64>) -> u64 { let mins = match feed.schedule.as_deref().and_then(config::parse_interval) { Some(explicit) => explicit, None => ttl_mins.unwrap_or(0).max(cfg.general.interval()), }; mins * 60 } #[derive(Default)] struct Scan { new_entries: usize, downloaded: usize, failed: usize, torrents: usize, } /// What a scan of one feed turned out to be. enum Outcome { NotModified, Feed(Scan), /// The URL served an OPML document, so it is a subscription list rather than a feed. Opml { added: Vec<String>, removed: usize, kept: usize, total: usize }, } async fn scan_one( ctx: &Arc<Ctx>, id: &str, feed_cfg: &config::Feed, state: &db::HttpState, ) -> Result<Outcome> { let mut fetched = feed::fetch( &ctx.client, feed_cfg, state.etag.as_deref(), state.last_modified.as_deref(), ) .await?; // A 304 while nothing is stored means the validator has outlived the data -- a restore // from backup, a manual edit, a cleanup that removed entries. Believe the database over // the validator: drop it and ask again, or the feed stays empty until the publisher // happens to change something. if matches!(fetched, feed::Fetched::NotModified) && ctx.db.feed_summary(id)?.entries == 0 { tracing::info!(feed = id, "not modified, but nothing stored; refetching without the validator"); ctx.db.clear_validators(id)?; fetched = feed::fetch(&ctx.client, feed_cfg, None, None).await?; } let (bytes, etag, last_modified) = match fetched { feed::Fetched::NotModified => { ctx.db.touch_feed(id, &feed_cfg.url)?; return Ok(Outcome::NotModified); } feed::Fetched::Body { bytes, etag, last_modified } => (bytes, etag, last_modified), }; // A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml" // URL; sniffing the body also catches one served from a URL without that extension. if feed::is_opml(&bytes) { ctx.db.touch_feed(id, &feed_cfg.url)?; return sync_opml(ctx, id, feed_cfg, &bytes).await; } let parsed = feed::parse(&bytes)?; ctx.db.record_feed( id, &feed_cfg.url, parsed.title.as_deref(), etag.as_deref(), last_modified.as_deref(), parsed.ttl_mins, parsed.image.as_deref(), )?; let mut scan = Scan::default(); for entry in &parsed.entries { if ctx.db.record_entry(id, entry)? { scan.new_entries += 1; } for enc in &entry.enclosures { if !ctx.db.record_enclosure(id, &entry.guid, enc)? { continue; // Seen before: downloaded, skipped or deliberately reaped. } // Filters run once, at discovery, and are recorded in `state`. The download // queue below is then just "everything still pending". if let Some(reason) = reject(&ctx.cfg(), feed_cfg, entry, enc) { ctx.db.mark_enclosure(&enc.url, "skipped", Some(reason))?; } } } // An unset per-feed cap follows the global one; 0 there means unlimited. let budget = feed_cfg.max_new_per_check.unwrap_or_else(|| { let g = ctx.cfg().general.max_new_per_check; if g == 0 { usize::MAX } else { g } }); if feed_cfg.auto_download && budget > 0 { let cfg = ctx.cfg(); let folder = download::folder_for(&cfg, id, feed_cfg, parsed.title.as_deref()); let dest_dir = cfg.general.download_dir.join(&folder); for item in ctx.db.pending(id, budget)? { if download::looks_like_torrent(&item.url, item.mime.as_deref()) { if !ctx.cfg().torrent.enabled { ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled"))?; ctx.out.emit(Event::TorrentDeferred { feed: id.to_string(), url: item.url.clone(), }); scan.torrents += 1; continue; } if ctx.detach_torrents { // 'downloading' keeps the next scan from queueing it a second time. ctx.db.mark_enclosure(&item.url, "downloading", None)?; spawn_torrent(ctx, id.to_string(), item.id, item.url.clone(), dest_dir.clone()); scan.torrents += 1; continue; } match torrent_one(ctx, id, item.id, &item.url, &dest_dir).await { Ok((path, bytes)) => { ctx.db.mark_downloaded(&item.url, &path, bytes)?; ctx.out.emit(Event::DownloadDone { feed: id.to_string(), enclosure: item.id, url: item.url.clone(), path: path.display().to_string(), bytes, }); scan.downloaded += 1; } Err(e) => { let msg = format!("{e:#}"); ctx.out.emit(Event::DownloadError { feed: id.to_string(), enclosure: item.id, url: item.url.clone(), msg: msg.clone(), }); ctx.db.mark_enclosure(&item.url, "error", Some(&msg))?; scan.failed += 1; } } continue; } match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await { Ok((path, bytes)) => { ctx.out.emit(Event::DownloadDone { feed: id.to_string(), enclosure: item.id, url: item.url.clone(), path: path.display().to_string(), bytes, }); scan.downloaded += 1; } Err(e) => { let msg = format!("{e:#}"); ctx.out.emit(Event::DownloadError { feed: id.to_string(), enclosure: item.id, url: item.url.clone(), msg: msg.clone(), }); ctx.db.mark_enclosure(&item.url, "error", Some(&msg))?; scan.failed += 1; } } } } Ok(Outcome::Feed(scan)) } /// Brings the feed list in step with a subscribed OPML. /// /// New entries are added under the OPML's group and folder. An entry that has gone from /// the OPML is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it /// is kept and flagged, because dropping it would orphan files on disk with nothing in /// the UI to explain them. async fn sync_opml( ctx: &Arc<Ctx>, parent_id: &str, parent: &config::Feed, bytes: &[u8], ) -> Result<Outcome> { let listed = feed::parse_opml(bytes)?; if let Some(title) = feed::opml_title(bytes) { ctx.db.set_title(parent_id, &title)?; } let cfg = ctx.cfg(); let existing = ctx.db.managed_feeds()?; let mut added = vec![]; for (title, url) in &listed { // Already known, whether derived or promoted into the config. if let Some(m) = existing.iter().find(|m| &m.url == url) { ctx.db.upsert_managed(&m.id, url, title, parent_id)?; continue; } if cfg.feeds.values().any(|f| &f.url == url) { continue; } let taken: std::collections::BTreeMap<String, config::Feed> = cfg .feeds .keys() .chain(existing.iter().map(|m| &m.id)) .chain(added.iter()) .map(|id| (id.clone(), parent.clone())) .collect(); let id = config::unique_slug(title, &taken); ctx.db.upsert_managed(&id, url, title, parent_id)?; added.push(id); } // Anything in this group the OPML no longer lists. let mut removed = 0; let mut kept = 0; for m in existing.iter().filter(|m| m.group_id == parent_id) { if listed.iter().any(|(_, u)| u == &m.url) { continue; } if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 { // Never orphan a downloaded file: keep the feed and say why in the UI. ctx.db.set_orphaned(&m.id, true)?; kept += 1; tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it"); } else { ctx.db.drop_managed(&m.id)?; removed += 1; tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed"); } } Ok(Outcome::Opml { added, removed, kept, total: listed.len() }) } /// Why this enclosure should not be downloaded, if it should not be. fn reject( cfg: &config::Config, feed_cfg: &config::Feed, entry: &feed::Entry, enc: &feed::Enclosure, ) -> Option<&'static str> { let url = enc.url.as_str(); if !feed_cfg.auto_download { return Some("auto_download is off"); } // Blog feeds put the article's header image in an <enclosure>; without this a text // feed reads as a podcast full of episodes and fills the disk with artwork. let wanted = feed_cfg .media_types .as_deref() .unwrap_or(&cfg.general.media_types); if !config::wanted_media(enc.mime.as_deref(), wanted) { return Some("not audio or video"); } if entry.explicit && !feed_cfg.allow_explicit { return Some("explicit"); } let categories = entry.categories.join(" "); let haystacks = [ url, entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or(""), categories.as_str(), ]; if !download::matches_keywords(&feed_cfg.keywords, &haystacks) { return Some("no keyword match"); } None } async fn fetch_one( ctx: &Arc<Ctx>, feed_id: &str, enclosure: i64, feed_cfg: &config::Feed, url: &str, dest_dir: &std::path::Path, ) -> Result<(PathBuf, u64)> { // Throttled to whole percents, as the original's lastDLStepSize guard did. let mut last_pct = -1i64; let name = download::filename_for(url, None); let cfg = ctx.cfg(); let got = download::download(&ctx.client, &cfg, feed_cfg, url, |done, total| { if let Some(t) = total.filter(|t| *t > 0) { let pct = (done * 100 / t) as i64; if pct > last_pct { last_pct = pct; ctx.out.emit(Event::Progress { feed: feed_id.to_string(), enclosure, url: url.to_string(), file: name.clone(), done, total, }); } } }) .await?; if matches!(got.kind, download::Kind::Torrent) { // The MIME lied. Hand the URL to the torrent session instead of filing a .torrent // as if it were an episode. let _ = tokio::fs::remove_file(&got.tmp).await; if !ctx.cfg().torrent.enabled { ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?; anyhow::bail!("body is a torrent and torrents are disabled"); } return torrent_one(ctx, feed_id, enclosure, url, dest_dir).await; } let path = download::place(&got, dest_dir).await?; ctx.db.mark_downloaded(url, &path, got.bytes)?; Ok((path, got.bytes)) } /// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving /// while it fetches metadata, transfers and then seeds. fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, dest_dir: PathBuf) { let ctx = ctx.clone(); tokio::spawn(async move { // Held for the whole job, so a feed full of torrents cannot open hundreds at once. let _permit = match ctx.torrent_slots.clone().acquire_owned().await { Ok(p) => p, Err(_) => return, }; let outcome = torrent_one(&ctx, &feed_id, enclosure, &url, &dest_dir).await; let db = &ctx.db; match outcome { Ok((path, bytes)) => { if let Err(e) = db.mark_downloaded(&url, &path, bytes) { tracing::warn!(error = ?e, "could not record the finished torrent"); } ctx.out.emit(Event::DownloadDone { feed: feed_id, enclosure, url, path: path.display().to_string(), bytes, }); } Err(e) => { let msg = format!("{e:#}"); let _ = db.mark_enclosure(&url, "error", Some(&msg)); ctx.out.emit(Event::DownloadError { feed: feed_id, enclosure, url, msg }); } } }); } /// Downloads one specific enclosure immediately, whatever the per-scan cap says and /// wherever it sits in the queue. async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> { let cfg = ctx.cfg(); let enc = ctx .db .enclosure(id)? .ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?; if enc.path.is_some() { return Ok(()); // Already here. } // Must look through the derived feeds too: anything inside an OPML subscription has // no config entry, so a config-only lookup called every one of them "unsubscribed". let subs = subscriptions(ctx)?; let feed_cfg = subs .iter() .find(|s| s.id == enc.feed_id) .map(|s| s.cfg.clone()) .ok_or_else(|| anyhow::anyhow!("enclosure {id} belongs to unsubscribed feed {:?}", enc.feed_id))?; let feed_cfg = &feed_cfg; let title = ctx.db.feed_summary(&enc.feed_id)?.title; let folder = download::folder_for(&cfg, &enc.feed_id, feed_cfg, title.as_deref()); let dest_dir = cfg.general.download_dir.join(&folder); ctx.out.emit(Event::FeedStart { feed: enc.feed_id.clone() }); let is_torrent = download::looks_like_torrent(&enc.url, enc.mime.as_deref()); if is_torrent && cfg.torrent.enabled && ctx.detach_torrents { ctx.db.mark_enclosure(&enc.url, "downloading", None)?; spawn_torrent(ctx, enc.feed_id.clone(), enc.id, enc.url.clone(), dest_dir); return Ok(()); } let result = if is_torrent { if !cfg.torrent.enabled { Err(anyhow::anyhow!("torrents are disabled")) } else { torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await } } else { fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await }; match result { Ok((path, bytes)) => { ctx.db.mark_downloaded(&enc.url, &path, bytes)?; ctx.out.emit(Event::DownloadDone { feed: enc.feed_id.clone(), enclosure: enc.id, url: enc.url.clone(), path: path.display().to_string(), bytes, }); } Err(e) => { let msg = format!("{e:#}"); ctx.db.mark_enclosure(&enc.url, "error", Some(&msg))?; ctx.out.emit(Event::DownloadError { feed: enc.feed_id.clone(), enclosure: enc.id, url: enc.url.clone(), msg, }); } } // Terminal, so a UI waiting on this request stops here. ctx.out.emit(Event::ScanDone { feeds: 1 }); Ok(()) } /// Torrent progress is reported the same way an HTTP download's is, throttled to whole /// percents so a UI is not flooded. async fn torrent_one( ctx: &Arc<Ctx>, feed_id: &str, enclosure: i64, url: &str, dest_dir: &std::path::Path, ) -> Result<(PathBuf, u64)> { let name = download::filename_for(url, None); let mut last_pct = -1i64; let cfg = ctx.cfg(); ctx.torrents() .await? .fetch(&cfg, url, dest_dir, |done, total| { if total > 0 { let pct = (done * 100 / total) as i64; if pct > last_pct { last_pct = pct; ctx.out.emit(Event::Progress { feed: feed_id.to_string(), enclosure, url: url.to_string(), file: name.clone(), done, total: Some(total), }); } } }) .await } fn ago(t: Option<i64>) -> String { let Some(t) = t else { return "never".into() }; format!("{} ago", duration((db::now() - t).max(0) as u64)) } fn duration(secs: u64) -> String { match secs { s if s < 90 => format!("{s}s"), s if s < 5400 => format!("{}m", s / 60), s if s < 172_800 => format!("{}h", s / 3600), s => format!("{}d", s / 86_400), } }