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:
@@ -34,6 +34,10 @@ pub struct General {
|
||||
pub max_total_gb: f64,
|
||||
/// 0 = keep forever.
|
||||
pub max_age_days: u64,
|
||||
/// How many new enclosures a single scan may take, when a feed does not say.
|
||||
/// Unlimited by default was a trap: subscribing to an OPML of 80 feeds then pulled
|
||||
/// every back-catalogue episode at once. 0 means unlimited, deliberately chosen.
|
||||
pub max_new_per_check: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -104,7 +108,7 @@ pub struct Feed {
|
||||
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schedule: Option<String>,
|
||||
/// Cap on new downloads per scan. None = unlimited.
|
||||
/// Cap on new downloads per scan for this feed. None follows `[general]`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_new_per_check: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -130,6 +134,7 @@ impl Default for General {
|
||||
organize: Organize::Feed,
|
||||
max_total_gb: 0.0,
|
||||
max_age_days: 0,
|
||||
max_new_per_check: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
src/db.rs
117
src/db.rs
@@ -23,7 +23,13 @@ CREATE TABLE IF NOT EXISTS feeds (
|
||||
ttl_mins INTEGER,
|
||||
last_error TEXT,
|
||||
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||
orphaned INTEGER NOT NULL DEFAULT 0
|
||||
orphaned INTEGER NOT NULL DEFAULT 0,
|
||||
-- The OPML subscription this feed came from.
|
||||
group_id TEXT,
|
||||
-- 1 = derived from an OPML and not written to config.toml. Writing 80-odd generated
|
||||
-- entries into a hand-edited file made it unreadable; the OPML is the source of
|
||||
-- truth, so they are re-derived instead. Customising one promotes it to config.
|
||||
managed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
@@ -65,12 +71,24 @@ CREATE TABLE IF NOT EXISTS enclosures (
|
||||
CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
|
||||
";
|
||||
|
||||
/// A feed derived from an OPML subscription rather than written into the config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Managed {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub group_id: String,
|
||||
pub orphaned: bool,
|
||||
}
|
||||
|
||||
/// Adds columns that later versions introduced. CREATE TABLE IF NOT EXISTS does nothing to
|
||||
/// a table that already exists, so an installed database needs them added explicitly.
|
||||
fn migrate(conn: &Connection) -> Result<()> {
|
||||
let wanted: &[(&str, &str, &str)] = &[
|
||||
("feeds", "image", "TEXT"),
|
||||
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("feeds", "group_id", "TEXT"),
|
||||
("feeds", "managed", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("entries", "image", "TEXT"),
|
||||
("entries", "duration", "INTEGER"),
|
||||
("entries", "episode", "INTEGER"),
|
||||
@@ -355,8 +373,13 @@ impl Db {
|
||||
pub fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, mime FROM enclosures
|
||||
WHERE feed_id = ?1 AND state = 'pending' ORDER BY id LIMIT ?2",
|
||||
// Newest first: a cap of 3 should mean the three latest episodes, not the
|
||||
// three that happen to have been recorded first.
|
||||
"SELECT x.id, x.url, x.mime FROM enclosures x
|
||||
JOIN entries e ON e.feed_id = x.feed_id AND e.guid = x.guid
|
||||
WHERE x.feed_id = ?1 AND x.state = 'pending'
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, x.id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![feed_id, limit as i64], |r| {
|
||||
@@ -725,6 +748,77 @@ impl Db {
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Names a feed without touching its conditional-GET validators. An OPML subscription
|
||||
/// takes its name from the document's own <head><title>.
|
||||
pub fn set_title(&self, feed_id: &str, title: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE feeds SET title = ?2 WHERE id = ?1 AND coalesce(title, '') != ?2",
|
||||
rusqlite::params![feed_id, title],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records a feed that came from an OPML. Its settings are the parent's; only what
|
||||
/// identifies it is stored.
|
||||
pub fn upsert_managed(
|
||||
&self,
|
||||
id: &str,
|
||||
url: &str,
|
||||
title: &str,
|
||||
group_id: &str,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, title, group_id, managed, orphaned)
|
||||
VALUES (?1, ?2, ?3, ?4, 1, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
url = excluded.url,
|
||||
title = coalesce(feeds.title, excluded.title),
|
||||
group_id = excluded.group_id,
|
||||
managed = 1,
|
||||
orphaned = 0",
|
||||
rusqlite::params![id, url, title, group_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every feed derived from an OPML, whichever group.
|
||||
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, title, group_id, orphaned FROM feeds
|
||||
WHERE managed = 1 AND group_id IS NOT NULL ORDER BY coalesce(title, id)",
|
||||
)?;
|
||||
Ok(stmt
|
||||
.query_map([], |r| {
|
||||
Ok(Managed {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
group_id: r.get(3)?,
|
||||
orphaned: r.get::<_, i64>(4)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Forgets a derived feed entirely. Only for one with nothing downloaded.
|
||||
pub fn drop_managed(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM feeds WHERE id = ?1 AND managed = 1", [id])?;
|
||||
conn.execute("DELETE FROM entries WHERE feed_id = ?1", [id])?;
|
||||
conn.execute("DELETE FROM enclosures WHERE feed_id = ?1 AND path IS NULL", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops treating a feed as derived, because it now has its own config entry.
|
||||
pub fn unmanage(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("UPDATE feeds SET managed = 0 WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_orphaned(&self, feed_id: &str, on: bool) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
@@ -825,6 +919,23 @@ mod tests {
|
||||
"search is case-insensitive and covers the description");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_takes_the_latest_episodes_first() {
|
||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO entries (feed_id, guid, published, first_seen) VALUES
|
||||
('f','old',100,100), ('f','mid',200,200), ('f','new',300,300);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, state) VALUES
|
||||
(1,'f','old','u-old','pending'),
|
||||
(2,'f','mid','u-mid','pending'),
|
||||
(3,'f','new','u-new','pending');",
|
||||
)
|
||||
.unwrap();
|
||||
let got: Vec<String> = db.pending("f", 2).unwrap().into_iter().map(|p| p.url).collect();
|
||||
assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_requeues_interrupted_downloads() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
35
src/ipc.rs
35
src/ipc.rs
@@ -73,9 +73,9 @@ impl Event {
|
||||
}
|
||||
Event::Error { msg } => format!("error: {msg}"),
|
||||
// Noise in a terminal; a UI still gets them on the socket.
|
||||
Event::FeedStart { .. } | Event::TorrentDeferred { .. } | Event::ScanDone { .. } => {
|
||||
return None;
|
||||
}
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,35 @@ impl Emitter {
|
||||
}
|
||||
|
||||
pub fn emit(&self, e: Event) {
|
||||
// Also log it. Scans and downloads travel as events, not tracing calls, so
|
||||
// without this the log view shows only startup and HTTP lines and none of the
|
||||
// work the daemon is actually doing. Progress goes to debug: it fires on every
|
||||
// whole percent and would otherwise crowd everything else out of the buffer.
|
||||
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
||||
// line per feed per tick for "not due yet" would push everything worth reading
|
||||
// out of the buffer within a few minutes.
|
||||
let routine = match &e {
|
||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
||||
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
||||
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let bad = matches!(
|
||||
&e,
|
||||
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
|
||||
);
|
||||
if let Some(line) = e.human() {
|
||||
let line = line.trim();
|
||||
if bad {
|
||||
tracing::warn!(target: "ipx::scan", "{line}");
|
||||
} else if routine {
|
||||
tracing::debug!(target: "ipx::scan", "{line}");
|
||||
} else {
|
||||
tracing::info!(target: "ipx::scan", "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tx) = &self.tx {
|
||||
// An error here only means nobody is listening yet.
|
||||
let _ = tx.send(e.clone());
|
||||
|
||||
188
src/main.rs
188
src/main.rs
@@ -217,6 +217,12 @@ async fn daemon(
|
||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||
}
|
||||
|
||||
match migrate_opml_children(&ctx) {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = ?e, "could not tidy OPML feeds out of the config"),
|
||||
}
|
||||
|
||||
match ctx.db.requeue_interrupted() {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
|
||||
Ok(_) => {}
|
||||
@@ -549,19 +555,17 @@ fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
||||
|
||||
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
let subs = subscriptions(ctx)?;
|
||||
if let Some(id) = only
|
||||
&& !cfg.feeds.contains_key(id)
|
||||
&& !subs.iter().any(|s| s.id == id)
|
||||
{
|
||||
anyhow::bail!("no feed with id {id:?}");
|
||||
}
|
||||
|
||||
let mut scanned = 0;
|
||||
let mut fresh: Vec<String> = vec![];
|
||||
for (id, feed_cfg) in cfg
|
||||
.feeds
|
||||
.iter()
|
||||
.filter(|(id, _)| only.is_none_or(|o| o == *id))
|
||||
{
|
||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
||||
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
||||
let state = ctx.db.http_state(id)?;
|
||||
|
||||
if !force && let Some(last) = state.last_checked {
|
||||
@@ -611,9 +615,11 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||
}
|
||||
// Feeds a subscribed OPML just introduced: scan them now, in this run.
|
||||
if !fresh.is_empty() {
|
||||
let cfg = ctx.cfg();
|
||||
let subs = subscriptions(ctx)?;
|
||||
for id in &fresh {
|
||||
let Some(feed_cfg) = cfg.feeds.get(id) else { continue };
|
||||
let Some(feed_cfg) = subs.iter().find(|s| &s.id == id).map(|s| &s.cfg) else {
|
||||
continue;
|
||||
};
|
||||
scanned += 1;
|
||||
ctx.out.emit(Event::FeedStart { feed: id.clone() });
|
||||
let state = ctx.db.http_state(id)?;
|
||||
@@ -638,6 +644,89 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One feed to scan: either an entry you wrote in config.toml, or one derived from an
|
||||
/// OPML subscription and held only in the database.
|
||||
pub struct Sub {
|
||||
pub id: String,
|
||||
pub cfg: config::Feed,
|
||||
/// True when it came from an OPML and has no config entry of its own.
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// Everything to scan: your config entries, plus whatever the OPML subscriptions listed.
|
||||
///
|
||||
/// A derived feed borrows its parent's settings wholesale. That is why it needs no config
|
||||
/// entry -- there is nothing to store but its URL and where it came from.
|
||||
pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
let cfg = ctx.cfg();
|
||||
let mut out: Vec<Sub> = cfg
|
||||
.feeds
|
||||
.iter()
|
||||
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
|
||||
.collect();
|
||||
|
||||
for m in ctx.db.managed_feeds()? {
|
||||
if cfg.feeds.contains_key(&m.id) {
|
||||
continue; // promoted to config at some point; that entry wins
|
||||
}
|
||||
let parent = cfg.feeds.get(&m.group_id);
|
||||
let base = parent
|
||||
.and_then(|p| p.folder.clone())
|
||||
.or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title))
|
||||
.unwrap_or_else(|| m.group_id.clone());
|
||||
let title = m.title.clone().unwrap_or_else(|| m.id.clone());
|
||||
out.push(Sub {
|
||||
id: m.id.clone(),
|
||||
cfg: config::Feed {
|
||||
url: m.url.clone(),
|
||||
folder: Some(format!("{base}/{title}")),
|
||||
group: Some(m.group_id.clone()),
|
||||
schedule: parent.and_then(|p| p.schedule.clone()),
|
||||
keywords: parent.map(|p| p.keywords.clone()).unwrap_or_default(),
|
||||
allow_explicit: parent.is_some_and(|p| p.allow_explicit),
|
||||
auto_download: parent.is_none_or(|p| p.auto_download),
|
||||
max_new_per_check: parent.and_then(|p| p.max_new_per_check),
|
||||
username: parent.and_then(|p| p.username.clone()),
|
||||
password: parent.and_then(|p| p.password.clone()),
|
||||
password_env: parent.and_then(|p| p.password_env.clone()),
|
||||
},
|
||||
managed: true,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Moves OPML children that older versions wrote into config.toml over to the database.
|
||||
/// They were never yours to edit, and 80-odd of them made the file unreadable.
|
||||
fn migrate_opml_children(ctx: &Ctx) -> Result<usize> {
|
||||
let cfg = (*ctx.cfg()).clone();
|
||||
let children: Vec<(String, config::Feed)> = cfg
|
||||
.feeds
|
||||
.iter()
|
||||
.filter(|(_, f)| f.group.is_some())
|
||||
.map(|(id, f)| (id.clone(), f.clone()))
|
||||
.collect();
|
||||
if children.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut fresh = cfg.clone();
|
||||
for (id, f) in &children {
|
||||
let group = f.group.clone().unwrap_or_default();
|
||||
let title = ctx
|
||||
.db
|
||||
.feed_summary(id)
|
||||
.ok()
|
||||
.and_then(|s| s.title)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
ctx.db.upsert_managed(id, &f.url, &title, &group)?;
|
||||
fresh.feeds.remove(id);
|
||||
}
|
||||
fresh.save(&ctx.config_path)?;
|
||||
ctx.reload_cfg(&ctx.config_path)?;
|
||||
Ok(children.len())
|
||||
}
|
||||
|
||||
/// Seconds to wait before re-checking a feed.
|
||||
///
|
||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
||||
@@ -724,7 +813,11 @@ async fn scan_one(
|
||||
}
|
||||
}
|
||||
|
||||
let budget = feed_cfg.max_new_per_check.unwrap_or(usize::MAX);
|
||||
// An unset per-feed cap follows the global one; 0 there means unlimited.
|
||||
let budget = feed_cfg.max_new_per_check.unwrap_or_else(|| {
|
||||
let g = ctx.cfg().general.max_new_per_check;
|
||||
if g == 0 { usize::MAX } else { g }
|
||||
});
|
||||
if feed_cfg.auto_download && budget > 0 {
|
||||
let cfg = ctx.cfg();
|
||||
let folder = download::folder_for(&cfg, id, feed_cfg, parsed.title.as_deref());
|
||||
@@ -816,71 +909,54 @@ async fn sync_opml(
|
||||
bytes: &[u8],
|
||||
) -> Result<Outcome> {
|
||||
let listed = feed::parse_opml(bytes)?;
|
||||
let mut cfg = (*ctx.cfg()).clone();
|
||||
|
||||
let base_folder = parent
|
||||
.folder
|
||||
.clone()
|
||||
.or_else(|| ctx.db.feed_summary(parent_id).ok().and_then(|s| s.title))
|
||||
.unwrap_or_else(|| parent_id.to_owned());
|
||||
if let Some(title) = feed::opml_title(bytes) {
|
||||
ctx.db.set_title(parent_id, &title)?;
|
||||
}
|
||||
|
||||
let cfg = ctx.cfg();
|
||||
let existing = ctx.db.managed_feeds()?;
|
||||
let mut added = vec![];
|
||||
|
||||
for (title, url) in &listed {
|
||||
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| &f.url == url) {
|
||||
// Already subscribed. If it had been flagged as gone, it is back.
|
||||
let id = id.clone();
|
||||
let _ = ctx.db.set_orphaned(&id, false);
|
||||
// Already known, whether derived or promoted into the config.
|
||||
if let Some(m) = existing.iter().find(|m| &m.url == url) {
|
||||
ctx.db.upsert_managed(&m.id, url, title, parent_id)?;
|
||||
continue;
|
||||
}
|
||||
let id = config::unique_slug(title, &cfg.feeds);
|
||||
cfg.feeds.insert(
|
||||
id.clone(),
|
||||
config::Feed {
|
||||
url: url.clone(),
|
||||
// Nested, so the whole subscription lands in one folder.
|
||||
folder: Some(format!("{base_folder}/{title}")),
|
||||
group: Some(parent_id.to_owned()),
|
||||
schedule: parent.schedule.clone(),
|
||||
keywords: parent.keywords.clone(),
|
||||
allow_explicit: parent.allow_explicit,
|
||||
auto_download: parent.auto_download,
|
||||
max_new_per_check: parent.max_new_per_check,
|
||||
username: parent.username.clone(),
|
||||
password: parent.password.clone(),
|
||||
password_env: parent.password_env.clone(),
|
||||
},
|
||||
);
|
||||
if cfg.feeds.values().any(|f| &f.url == url) {
|
||||
continue;
|
||||
}
|
||||
let taken: std::collections::BTreeMap<String, config::Feed> = cfg
|
||||
.feeds
|
||||
.keys()
|
||||
.chain(existing.iter().map(|m| &m.id))
|
||||
.chain(added.iter())
|
||||
.map(|id| (id.clone(), parent.clone()))
|
||||
.collect();
|
||||
let id = config::unique_slug(title, &taken);
|
||||
ctx.db.upsert_managed(&id, url, title, parent_id)?;
|
||||
added.push(id);
|
||||
}
|
||||
|
||||
// Anything in this group the OPML no longer lists.
|
||||
let gone: Vec<String> = cfg
|
||||
.feeds
|
||||
.iter()
|
||||
.filter(|(_, f)| f.group.as_deref() == Some(parent_id))
|
||||
.filter(|(_, f)| !listed.iter().any(|(_, u)| u == &f.url))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
let mut removed = 0;
|
||||
let mut kept = 0;
|
||||
for id in gone {
|
||||
if ctx.db.downloaded_count(&id).unwrap_or(1) > 0 {
|
||||
for m in existing.iter().filter(|m| m.group_id == parent_id) {
|
||||
if listed.iter().any(|(_, u)| u == &m.url) {
|
||||
continue;
|
||||
}
|
||||
if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 {
|
||||
// Never orphan a downloaded file: keep the feed and say why in the UI.
|
||||
ctx.db.set_orphaned(&id, true)?;
|
||||
ctx.db.set_orphaned(&m.id, true)?;
|
||||
kept += 1;
|
||||
tracing::info!(feed = %id, "dropped from the OPML but has downloads; keeping it");
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it");
|
||||
} else {
|
||||
cfg.feeds.remove(&id);
|
||||
ctx.db.drop_managed(&m.id)?;
|
||||
removed += 1;
|
||||
tracing::info!(feed = %id, "dropped from the OPML with nothing downloaded; unsubscribed");
|
||||
tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed");
|
||||
}
|
||||
}
|
||||
|
||||
if !added.is_empty() || removed > 0 {
|
||||
cfg.save(&ctx.config_path)?;
|
||||
ctx.reload_cfg(&ctx.config_path)?;
|
||||
}
|
||||
Ok(Outcome::Opml { added, removed, kept, total: listed.len() })
|
||||
}
|
||||
|
||||
|
||||
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