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:
2026-09-10 15:15:54 +00:00
parent c86d698363
commit 665d5b8ecb
9 changed files with 456 additions and 77 deletions

View File

@@ -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);
}