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:
156
src/db.rs
Normal file
156
src/db.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user