Subscribe to an OPML, not just import one
A feed whose body sniffs as OPML is treated as a subscription list and re-read on every scan, as iPodderX did. Listed feeds become real config entries grouped under it, inherit its settings, land in one nested folder, and are scanned in the same run. When a feed leaves the OPML: removed if nothing was downloaded, kept and flagged otherwise, so a downloaded file is never orphaned. folder_for sanitized the whole folder string and would have flattened the nesting; each segment is sanitized separately now, and a traversal still cannot escape the download directory. Db::memory() also runs migrate(), which it did not, so a migration-only column passed tests while missing in production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
36
src/db.rs
36
src/db.rs
@@ -21,7 +21,9 @@ CREATE TABLE IF NOT EXISTS feeds (
|
||||
last_modified TEXT,
|
||||
last_checked INTEGER,
|
||||
ttl_mins INTEGER,
|
||||
last_error TEXT
|
||||
last_error TEXT,
|
||||
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||
orphaned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
@@ -68,6 +70,7 @@ CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
|
||||
fn migrate(conn: &Connection) -> Result<()> {
|
||||
let wanted: &[(&str, &str, &str)] = &[
|
||||
("feeds", "image", "TEXT"),
|
||||
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("entries", "image", "TEXT"),
|
||||
("entries", "duration", "INTEGER"),
|
||||
("entries", "episode", "INTEGER"),
|
||||
@@ -92,6 +95,9 @@ fn migrate(conn: &Connection) -> Result<()> {
|
||||
pub struct FeedSummary {
|
||||
pub title: Option<String>,
|
||||
pub image: Option<String>,
|
||||
/// Came from a subscribed OPML that no longer lists it, but it has downloads, so it
|
||||
/// was kept rather than removed.
|
||||
pub orphaned: bool,
|
||||
pub last_checked: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
pub entries: i64,
|
||||
@@ -119,6 +125,9 @@ impl Db {
|
||||
pub fn memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
// Same path as a real open, so a column added only in migrate() cannot pass the
|
||||
// tests while being missing in production (or the reverse).
|
||||
migrate(&conn)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
@@ -132,7 +141,8 @@ impl Db {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut sum: FeedSummary = conn
|
||||
.query_row(
|
||||
"SELECT title, image, last_checked, last_error FROM feeds WHERE id = ?1",
|
||||
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0)
|
||||
FROM feeds WHERE id = ?1",
|
||||
[feed_id],
|
||||
|r| {
|
||||
Ok(FeedSummary {
|
||||
@@ -140,6 +150,7 @@ impl Db {
|
||||
image: r.get(1)?,
|
||||
last_checked: r.get(2)?,
|
||||
last_error: r.get(3)?,
|
||||
orphaned: r.get::<_, i64>(4)? != 0,
|
||||
..Default::default()
|
||||
})
|
||||
},
|
||||
@@ -703,6 +714,27 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many files this feed has on disk. Decides whether a feed dropped from an OPML
|
||||
/// can be removed or must be kept.
|
||||
pub fn downloaded_count(&self, feed_id: &str) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT count(*) FROM enclosures WHERE feed_id = ?1 AND path IS NOT NULL",
|
||||
[feed_id],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn set_orphaned(&self, feed_id: &str, on: bool) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, orphaned) VALUES (?1, '', ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET orphaned = excluded.orphaned",
|
||||
rusqlite::params![feed_id, on as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nothing can be in flight the moment the daemon starts, so any row still marked
|
||||
/// `downloading` is a leftover from a restart or a crash. Left alone it would sit
|
||||
/// there forever: the pending queue skips it and nothing else ever revisits it.
|
||||
|
||||
Reference in New Issue
Block a user