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:
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();
|
||||
|
||||
Reference in New Issue
Block a user