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:
72
src/main.rs
72
src/main.rs
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user