Add feed check scheduling, global with per-feed override

general.schedule and feeds.<id>.schedule take "every 30m", "4h", "1d" or
bare minutes. The legacy interval_mins is still read. An explicit per-feed
schedule wins over the publisher's ttl; without one, ttl still raises the
interval when they ask to be polled less often.

Fixes two bugs found while testing it:

null never cleared a field. serde maps JSON null onto the outer None of an
Option<Option<T>>, so "clear this" was indistinguishable from "not
supplied" and every clear silently no-opped with a 204.

The daemon ignored SIGTERM while working. select! races branches only at
selection time, so a signal queued behind an in-flight download and the
process had to be SIGKILLed. The stop signal now cancels work in progress:
SIGTERM mid-download exits in 1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-10 02:25:38 +00:00
parent 9c5e7716c5
commit 9dc4c1ddfa
5 changed files with 316 additions and 19 deletions

View File

@@ -12,7 +12,8 @@ use clap::{Parser, Subcommand};
use ipc::{Command as Cmd, Emitter, Event};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use std::future::Future;
use tokio::sync::{broadcast, mpsc, watch};
#[derive(Parser)]
#[command(name = "ipx", version, about = "A headless podcatcher")]
@@ -202,21 +203,57 @@ async fn daemon(ctx: Ctx, config_path: PathBuf, web_addr: Option<String>) -> Res
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tracing::info!(feeds = ctx.cfg().feeds.len(), "daemon started");
loop {
// 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<bool>,
job: impl Future<Output = Result<()>>,
) -> 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() => {
tracing::info!(?cmd, "command from a client");
if let Err(e) = run(&ctx, cmd).await {
ctx.out.emit(Event::Error { msg: format!("{e:#}") });
if !until_stopped(&ctx, &rx_stop, run(&ctx, cmd)).await {
break;
}
}
_ = ticker.tick() => {
// Per-feed TTL decides what actually gets polled.
if let Err(e) = run(&ctx, Cmd::Fetch { feed: None, force: false }).await {
ctx.out.emit(Event::Error { msg: format!("{e:#}") });
// 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;
}
}
_ = shutdown() => break,
}
}
@@ -318,6 +355,7 @@ pub async fn add_one(
let probe = config::Feed {
url: url.to_owned(),
folder: folder.clone(),
schedule: None,
keywords: keywords.clone(),
allow_explicit: false,
auto_download: true,
@@ -383,6 +421,7 @@ async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path)
config::Feed {
url,
folder: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
@@ -487,10 +526,8 @@ async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
{
let state = ctx.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;
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(),
@@ -526,6 +563,19 @@ async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
Ok(())
}
/// 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,