Keep OPML feeds out of config, cap downloads, log daemon work
Writing 82 derived feeds into a hand-edited config.toml made it unreadable. The OPML is the source of truth, so its feeds are re-derived each scan and held in the database, inheriting the subscription's settings; editing one promotes it to a real entry. A migration moves existing children out -- 611 lines to 38 -- keeping all entries and files. max_new_per_check defaulted to unlimited, so subscribing to an OPML of 82 feeds pulled whole back catalogues. It now defaults to 3 via [general], capping every feed that does not set its own, and the pending queue orders by publish date so a cap of 3 means the three newest. Scans and downloads travelled as socket events only, so the log view showed no daemon activity. They are mirrored into tracing, with routine skips at debug -- at 82 feeds those alone would flush the buffer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
33
src/web.rs
33
src/web.rs
@@ -159,6 +159,7 @@ struct FeedRow {
|
||||
group: Option<String>,
|
||||
/// In a group, but the OPML no longer lists it. Kept because it has downloads.
|
||||
orphaned: bool,
|
||||
managed: bool,
|
||||
schedule: Option<String>,
|
||||
/// The feed's own override in minutes, so the UI need not re-parse the string.
|
||||
schedule_mins: Option<u64>,
|
||||
@@ -174,8 +175,11 @@ struct FeedRow {
|
||||
|
||||
async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiError> {
|
||||
let cfg = state.ctx.cfg();
|
||||
let mut out = Vec::with_capacity(cfg.feeds.len());
|
||||
for (id, feed) in &cfg.feeds {
|
||||
// Config entries plus the feeds derived from OPML subscriptions.
|
||||
let subs = crate::subscriptions(&state.ctx)?;
|
||||
let mut out = Vec::with_capacity(subs.len());
|
||||
for sub in &subs {
|
||||
let (id, feed) = (&sub.id, &sub.cfg);
|
||||
let s = state.ctx.db.feed_summary(id)?;
|
||||
let st = state.ctx.db.http_state(id)?;
|
||||
out.push(FeedRow {
|
||||
@@ -190,6 +194,8 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
||||
max_new_per_check: feed.max_new_per_check,
|
||||
group: feed.group.clone(),
|
||||
orphaned: s.orphaned,
|
||||
// Derived from an OPML and not written to config until you change something.
|
||||
managed: sub.managed,
|
||||
schedule: feed.schedule.clone(),
|
||||
schedule_mins: feed
|
||||
.schedule
|
||||
@@ -435,6 +441,18 @@ async fn patch_feed(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let mut cfg = (*state.ctx.cfg()).clone();
|
||||
|
||||
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
||||
// entry: promote it, so the config holds your decisions and nothing else.
|
||||
if !cfg.feeds.contains_key(&id) {
|
||||
let subs = crate::subscriptions(&state.ctx)?;
|
||||
let found = subs
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||
state.ctx.db.unmanage(&id)?;
|
||||
}
|
||||
|
||||
let checked = match &body.url {
|
||||
Some(u) => Some(check_url(u, &id, &cfg.feeds).map_err(ApiError::bad_request)?),
|
||||
None => None,
|
||||
@@ -493,7 +511,10 @@ async fn remove_feed(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let mut cfg = (*state.ctx.cfg()).clone();
|
||||
if cfg.feeds.remove(&id).is_none() {
|
||||
return Err(anyhow::anyhow!("no feed with id {id:?}").into());
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
state.ctx.db.drop_managed(&id)?;
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
// Downloads and history stay, so re-adding does not re-pull the back catalogue.
|
||||
cfg.save(&state.config_path)?;
|
||||
@@ -750,6 +771,7 @@ async fn import_opml(
|
||||
struct Settings {
|
||||
schedule: String,
|
||||
every_mins: u64,
|
||||
max_new_per_check: usize,
|
||||
download_dir: String,
|
||||
max_total_gb: f64,
|
||||
max_age_days: u64,
|
||||
@@ -760,6 +782,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
Json(Settings {
|
||||
schedule: cfg.general.schedule.clone(),
|
||||
every_mins: cfg.general.interval(),
|
||||
max_new_per_check: cfg.general.max_new_per_check,
|
||||
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,
|
||||
@@ -769,6 +792,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
|
||||
#[derive(Deserialize)]
|
||||
struct SettingsPatch {
|
||||
schedule: Option<String>,
|
||||
max_new_per_check: Option<usize>,
|
||||
max_total_gb: Option<f64>,
|
||||
max_age_days: Option<u64>,
|
||||
}
|
||||
@@ -789,6 +813,9 @@ async fn patch_settings(
|
||||
// The legacy key would otherwise keep shadowing intent in the file.
|
||||
cfg.general.interval_mins = None;
|
||||
}
|
||||
if let Some(v) = body.max_new_per_check {
|
||||
cfg.general.max_new_per_check = v;
|
||||
}
|
||||
if let Some(v) = body.max_total_gb {
|
||||
cfg.general.max_total_gb = v.max(0.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user