Step 2: TOML config and SQLite state

config.rs replaces iPXSettings.py and feeds.plist; db.rs replaces the
per-feed .ipxd plists, history.dat and qmcache.dat. enclosures.url is
UNIQUE, which is the dedupe key the old pickle history provided.

ipx list is the first working subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-09 19:37:02 +00:00
parent 2c64208b8a
commit a13f19136c
6 changed files with 512 additions and 5 deletions

156
src/db.rs Normal file
View File

@@ -0,0 +1,156 @@
//! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension};
use std::path::Path;
use std::sync::Mutex;
/// ponytail: one global connection mutex. Writes here are tiny and rare; move to a
/// spawn_blocking pool if a large feed count ever makes it contend.
pub struct Db {
conn: Mutex<Connection>,
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT,
etag TEXT,
last_modified TEXT,
last_checked INTEGER,
ttl_mins INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS entries (
feed_id TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT,
link TEXT,
published INTEGER,
description TEXT,
first_seen INTEGER NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (feed_id, guid)
);
-- url is UNIQUE: this is the dedupe key, and it subsumes the old history.dat pickle.
-- A reaped file keeps its row with path = NULL and state = 'reaped', so a purged
-- episode is never fetched a second time.
CREATE TABLE IF NOT EXISTS enclosures (
id INTEGER PRIMARY KEY,
feed_id TEXT NOT NULL,
guid TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
mime TEXT,
length INTEGER,
path TEXT,
state TEXT NOT NULL,
bytes_done INTEGER NOT NULL DEFAULT 0,
downloaded_at INTEGER,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
";
/// What `ipx list` shows next to each configured feed.
#[derive(Debug, Default)]
pub struct FeedSummary {
pub title: Option<String>,
pub last_checked: Option<i64>,
pub last_error: Option<String>,
pub entries: i64,
pub downloaded: i64,
}
impl Db {
pub fn open(path: &Path) -> Result<Self> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating {}", dir.display()))?;
}
let conn = Connection::open(path)
.with_context(|| format!("opening {}", path.display()))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
conn.execute_batch(SCHEMA).context("creating schema")?;
Ok(Self { conn: Mutex::new(conn) })
}
/// In-memory database, for tests.
#[cfg(test)]
pub fn memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(SCHEMA)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn feed_summary(&self, feed_id: &str) -> Result<FeedSummary> {
let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn
.query_row(
"SELECT title, last_checked, last_error FROM feeds WHERE id = ?1",
[feed_id],
|r| {
Ok(FeedSummary {
title: r.get(0)?,
last_checked: r.get(1)?,
last_error: r.get(2)?,
..Default::default()
})
},
)
.optional()?
.unwrap_or_default();
sum.entries = conn.query_row(
"SELECT count(*) FROM entries WHERE feed_id = ?1",
[feed_id],
|r| r.get(0),
)?;
sum.downloaded = conn.query_row(
"SELECT count(*) FROM enclosures WHERE feed_id = ?1 AND path IS NOT NULL",
[feed_id],
|r| r.get(0),
)?;
Ok(sum)
}
}
/// Unix seconds. Everything time-shaped in the DB is stored this way.
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
let db = Db::memory().unwrap();
// Re-running the schema must not fail: open() does this on every start.
db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap();
let sum = db.feed_summary("never-seen").unwrap();
assert_eq!(sum.last_checked, None);
assert_eq!(sum.entries, 0);
assert_eq!(sum.downloaded, 0);
}
#[test]
fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap();
let conn = db.conn.lock().unwrap();
let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')";
conn.execute(insert, []).unwrap();
assert!(conn.execute(insert, []).is_err(), "duplicate url must be rejected");
}
}