Step 3: feed fetch and parse
Conditional GET plus an RSS-first, Atom-fallback parser normalising both into one entry model, with iTunes explicit and ttl handling carried over from the Python. Enclosures are recorded as pending; nothing downloads yet. GUID falls back guid -> link -> enclosure url -> title rather than hashing the description as the original did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
134
src/main.rs
134
src/main.rs
@@ -1,15 +1,17 @@
|
||||
mod config;
|
||||
mod db;
|
||||
mod feed;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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<std::path::PathBuf>,
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
@@ -19,28 +21,37 @@ struct Cli {
|
||||
enum Command {
|
||||
/// Show configured feeds and their state
|
||||
List,
|
||||
/// Scan feeds for new entries
|
||||
Fetch {
|
||||
/// Only this feed id
|
||||
feed: Option<String>,
|
||||
/// Poll even when the feed is not due yet
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli.config.unwrap_or_else(config::config_path);
|
||||
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"))?;
|
||||
|
||||
match cli.command {
|
||||
Command::List => list(&cfg, &db),
|
||||
Command::List => list(&cfg, &db, &config_path),
|
||||
Command::Fetch { feed, force } => fetch(&cfg, &db, feed.as_deref(), force).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn list(cfg: &config::Config, db: &db::Db) -> Result<()> {
|
||||
fn list(cfg: &config::Config, db: &db::Db, config_path: &std::path::Path) -> Result<()> {
|
||||
if cfg.feeds.is_empty() {
|
||||
println!("No feeds configured in {}", config::config_path().display());
|
||||
println!("No feeds configured in {}", config_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
for (id, feed) in &cfg.feeds {
|
||||
let s = db.feed_summary(id)?;
|
||||
let title = s.title.as_deref().unwrap_or("-");
|
||||
println!("{id} {title}");
|
||||
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);
|
||||
@@ -51,13 +62,110 @@ fn list(cfg: &config::Config, db: &db::Db) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
cfg: &config::Config,
|
||||
db: &db::Db,
|
||||
only: Option<&str>,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
|
||||
if let Some(id) = only
|
||||
&& !cfg.feeds.contains_key(id)
|
||||
{
|
||||
anyhow::bail!("no feed with id {id:?}");
|
||||
}
|
||||
|
||||
for (id, feed_cfg) in cfg.feeds.iter().filter(|(id, _)| only.is_none_or(|o| o == *id)) {
|
||||
let state = db.http_state(id)?;
|
||||
|
||||
// TTL: the feed's own <ttl> wins when it is longer than our poll interval.
|
||||
if !force && let Some(last) = state.last_checked {
|
||||
let wait = state.ttl_mins.unwrap_or(0).max(cfg.general.interval_mins) * 60;
|
||||
let due = last + wait as i64;
|
||||
if due > db::now() {
|
||||
println!("{id}: not due for {}", duration((due - db::now()) as u64));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match scan_one(&client, db, id, feed_cfg, &state).await {
|
||||
Ok(Some((new_entries, new_encs))) => {
|
||||
println!("{id}: {new_entries} new entries, {new_encs} new enclosures")
|
||||
}
|
||||
Ok(None) => println!("{id}: not modified"),
|
||||
Err(e) => {
|
||||
// One bad feed must not end the scan.
|
||||
let msg = format!("{e:#}");
|
||||
println!("{id}: error: {msg}");
|
||||
db.set_feed_error(id, &feed_cfg.url, &msg)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ok(None) means 304.
|
||||
async fn scan_one(
|
||||
client: &reqwest::Client,
|
||||
db: &db::Db,
|
||||
id: &str,
|
||||
feed_cfg: &config::Feed,
|
||||
state: &db::HttpState,
|
||||
) -> Result<Option<(usize, usize)>> {
|
||||
let fetched = feed::fetch(
|
||||
client,
|
||||
feed_cfg,
|
||||
state.etag.as_deref(),
|
||||
state.last_modified.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (bytes, etag, last_modified) = match fetched {
|
||||
feed::Fetched::NotModified => {
|
||||
db.touch_feed(id, &feed_cfg.url)?;
|
||||
return Ok(None);
|
||||
}
|
||||
feed::Fetched::Body { bytes, etag, last_modified } => (bytes, etag, last_modified),
|
||||
};
|
||||
|
||||
let parsed = feed::parse(&bytes)?;
|
||||
db.record_feed(
|
||||
id,
|
||||
&feed_cfg.url,
|
||||
parsed.title.as_deref(),
|
||||
etag.as_deref(),
|
||||
last_modified.as_deref(),
|
||||
parsed.ttl_mins,
|
||||
)?;
|
||||
|
||||
let mut new_entries = 0;
|
||||
let mut new_encs = 0;
|
||||
for entry in &parsed.entries {
|
||||
if db.record_entry(id, entry)? {
|
||||
new_entries += 1;
|
||||
}
|
||||
for enc in &entry.enclosures {
|
||||
if db.record_enclosure(id, &entry.guid, enc)? {
|
||||
new_encs += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some((new_entries, new_encs)))
|
||||
}
|
||||
|
||||
fn ago(t: Option<i64>) -> String {
|
||||
let Some(t) = t else { return "never".into() };
|
||||
let secs = (db::now() - t).max(0);
|
||||
format!("{} ago", duration((db::now() - t).max(0) as u64))
|
||||
}
|
||||
|
||||
fn duration(secs: u64) -> String {
|
||||
match secs {
|
||||
s if s < 90 => format!("{s}s ago"),
|
||||
s if s < 5400 => format!("{}m ago", s / 60),
|
||||
s if s < 172_800 => format!("{}h ago", s / 3600),
|
||||
s => format!("{}d ago", s / 86_400),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user