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:
114
src/web.rs
114
src/web.rs
@@ -71,6 +71,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/enclosures/{id}", delete(delete_file))
|
||||
.route("/api/fetch", post(fetch_now))
|
||||
.route("/api/opml", get(export_opml).post(import_opml))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/events", get(events))
|
||||
.route("/media/{id}", get(media))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
@@ -152,7 +153,11 @@ struct FeedRow {
|
||||
allow_explicit: bool,
|
||||
auto_download: bool,
|
||||
max_new_per_check: Option<usize>,
|
||||
schedule: Option<String>,
|
||||
/// Effective schedule in minutes, after the global default and the feed's <ttl>.
|
||||
every_mins: u64,
|
||||
last_checked: Option<i64>,
|
||||
next_check: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
entries: i64,
|
||||
downloaded: i64,
|
||||
@@ -164,6 +169,7 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
||||
let mut out = Vec::with_capacity(cfg.feeds.len());
|
||||
for (id, feed) in &cfg.feeds {
|
||||
let s = state.ctx.db.feed_summary(id)?;
|
||||
let st = state.ctx.db.http_state(id)?;
|
||||
out.push(FeedRow {
|
||||
id: id.clone(),
|
||||
url: feed.url.clone(),
|
||||
@@ -174,7 +180,12 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
||||
allow_explicit: feed.allow_explicit,
|
||||
auto_download: feed.auto_download,
|
||||
max_new_per_check: feed.max_new_per_check,
|
||||
schedule: feed.schedule.clone(),
|
||||
every_mins: crate::due_after(&cfg, feed, st.ttl_mins) / 60,
|
||||
last_checked: s.last_checked,
|
||||
next_check: s
|
||||
.last_checked
|
||||
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
|
||||
last_error: s.last_error,
|
||||
entries: s.entries,
|
||||
downloaded: s.downloaded,
|
||||
@@ -252,12 +263,29 @@ mod tests {
|
||||
|
||||
fn feed(url: &str) -> crate::config::Feed {
|
||||
crate::config::Feed {
|
||||
url: url.into(), folder: None, keywords: vec![], allow_explicit: false,
|
||||
url: url.into(), folder: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
auto_download: true, max_new_per_check: None, username: None,
|
||||
password: None, password_env: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_clears_a_field_while_absent_leaves_it_alone() {
|
||||
// serde maps null onto the outer None for a bare Option<Option<T>>, which made
|
||||
// "clear this field" indistinguishable from "field not supplied".
|
||||
let absent: FeedPatch = serde_json::from_str("{}").unwrap();
|
||||
assert!(absent.schedule.is_none() && absent.folder.is_none());
|
||||
|
||||
let cleared: FeedPatch =
|
||||
serde_json::from_str(r#"{"schedule":null,"folder":null,"max_new_per_check":null}"#).unwrap();
|
||||
assert_eq!(cleared.schedule, Some(None), "null must mean clear");
|
||||
assert_eq!(cleared.folder, Some(None));
|
||||
assert_eq!(cleared.max_new_per_check, Some(None));
|
||||
|
||||
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
||||
assert_eq!(set.schedule, Some(Some("every 6h".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_urls_are_validated_before_being_saved() {
|
||||
let mut feeds = std::collections::BTreeMap::new();
|
||||
@@ -359,17 +387,33 @@ async fn add_feed(
|
||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||
}
|
||||
|
||||
/// Only the fields that are present are changed.
|
||||
/// Absent means "leave alone"; JSON `null` means "clear this".
|
||||
///
|
||||
/// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a
|
||||
/// plain `Option<Option<T>>`, making the clear case unreachable and silently turning
|
||||
/// "unset the folder" into a no-op.
|
||||
#[derive(Deserialize)]
|
||||
struct FeedPatch {
|
||||
url: Option<String>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
schedule: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
folder: Option<Option<String>>,
|
||||
keywords: Option<Vec<String>>,
|
||||
allow_explicit: Option<bool>,
|
||||
auto_download: Option<bool>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
max_new_per_check: Option<Option<usize>>,
|
||||
}
|
||||
|
||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Deserialize::deserialize(de).map(Some)
|
||||
}
|
||||
|
||||
async fn patch_feed(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<String>,
|
||||
@@ -393,6 +437,17 @@ async fn patch_feed(
|
||||
feed.url = url;
|
||||
}
|
||||
|
||||
if let Some(v) = body.schedule {
|
||||
let v = v.map(|x| x.trim().to_owned()).filter(|x| !x.is_empty());
|
||||
if let Some(text) = &v
|
||||
&& crate::config::parse_interval(text).is_none()
|
||||
{
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"{text:?} is not a schedule — try \"every 30m\", \"every 4h\" or \"1d\""
|
||||
)));
|
||||
}
|
||||
feed.schedule = v;
|
||||
}
|
||||
if let Some(v) = body.folder {
|
||||
feed.folder = v.filter(|s| !s.trim().is_empty());
|
||||
}
|
||||
@@ -659,6 +714,7 @@ async fn import_opml(
|
||||
crate::config::Feed {
|
||||
url,
|
||||
folder: None,
|
||||
schedule: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
@@ -674,3 +730,57 @@ async fn import_opml(
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
Ok(Json(serde_json::json!({ "added": added })))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Settings {
|
||||
schedule: String,
|
||||
every_mins: u64,
|
||||
download_dir: String,
|
||||
max_total_gb: f64,
|
||||
max_age_days: u64,
|
||||
}
|
||||
|
||||
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
let cfg = state.ctx.cfg();
|
||||
Json(Settings {
|
||||
schedule: cfg.general.schedule.clone(),
|
||||
every_mins: cfg.general.interval(),
|
||||
download_dir: cfg.general.download_dir.display().to_string(),
|
||||
max_total_gb: cfg.general.max_total_gb,
|
||||
max_age_days: cfg.general.max_age_days,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SettingsPatch {
|
||||
schedule: Option<String>,
|
||||
max_total_gb: Option<f64>,
|
||||
max_age_days: Option<u64>,
|
||||
}
|
||||
|
||||
async fn patch_settings(
|
||||
State(state): State<WebState>,
|
||||
Json(body): Json<SettingsPatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let mut cfg = (*state.ctx.cfg()).clone();
|
||||
if let Some(sched) = body.schedule {
|
||||
let sched = sched.trim().to_owned();
|
||||
if crate::config::parse_interval(&sched).is_none() {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"{sched:?} is not a schedule — try \"every 30m\", \"every 4h\" or \"1d\""
|
||||
)));
|
||||
}
|
||||
cfg.general.schedule = sched;
|
||||
// The legacy key would otherwise keep shadowing intent in the file.
|
||||
cfg.general.interval_mins = None;
|
||||
}
|
||||
if let Some(v) = body.max_total_gb {
|
||||
cfg.general.max_total_gb = v.max(0.0);
|
||||
}
|
||||
if let Some(v) = body.max_age_days {
|
||||
cfg.general.max_age_days = v;
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user