Step 5: quota and age retention
Oldest-first reaper with the original's 50 MB headroom pad, plus a reconcile pass for files deleted by hand and pruning of stale entries. The Python meant to reap only read, unflagged episodes but a missing import and a typo made that filter throw on every candidate. Requiring read=1 would be equally dead headless, so flagged is the keep-forever marker and read only decides ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
94
src/db.rs
94
src/db.rs
@@ -89,6 +89,12 @@ impl Db {
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn exec_for_test(&self, sql: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute_batch(sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn feed_summary(&self, feed_id: &str) -> Result<FeedSummary> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut sum: FeedSummary = conn
|
||||
@@ -291,6 +297,94 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A downloaded file the reaper may consider.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Candidate {
|
||||
pub id: i64,
|
||||
pub url: String,
|
||||
pub path: String,
|
||||
pub bytes: i64,
|
||||
/// When it landed; the reaper works oldest-first.
|
||||
pub age_key: i64,
|
||||
pub read: bool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Files on disk, flagged ones excluded, read before unread and oldest first within
|
||||
/// each group.
|
||||
///
|
||||
/// The Python intended `read = 1 AND flagged = 0` but never achieved it (a missing
|
||||
/// plistlib import and an `EntreiesData` typo meant the filter always threw). Requiring
|
||||
/// `read = 1` outright would be just as dead here, since nothing marks episodes read
|
||||
/// until a UI exists -- so `flagged` is the keep-forever marker, and `read` only decides
|
||||
/// what goes first.
|
||||
pub fn reap_candidates(&self) -> Result<Vec<Candidate>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id, e.url, e.path, e.bytes_done,
|
||||
coalesce(e.downloaded_at, 0), coalesce(n.read, 0), coalesce(n.flagged, 0)
|
||||
FROM enclosures e
|
||||
LEFT JOIN entries n ON n.feed_id = e.feed_id AND n.guid = e.guid
|
||||
WHERE e.path IS NOT NULL AND coalesce(n.flagged, 0) = 0
|
||||
ORDER BY coalesce(n.read, 0) DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(Candidate {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
path: r.get(2)?,
|
||||
bytes: r.get(3)?,
|
||||
age_key: r.get(4)?,
|
||||
read: r.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The row survives the file: that is what stops a reaped episode being re-downloaded.
|
||||
pub fn mark_reaped(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE enclosures SET state = 'reaped', path = NULL WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rows claiming a file that is no longer there (someone deleted it by hand).
|
||||
pub fn missing_files(&self) -> Result<Vec<(i64, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, path FROM enclosures WHERE path IS NOT NULL")?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get::<_, String>(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|(_, p)| !std::path::Path::new(p).exists())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Old entries that never had a file, or no longer have one. Enclosure rows stay --
|
||||
/// they are the dedupe history.
|
||||
pub fn prune_entries(&self, older_than: i64) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let n = conn.execute(
|
||||
"DELETE FROM entries WHERE flagged = 0
|
||||
AND coalesce(published, first_seen) < ?1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM enclosures e
|
||||
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
|
||||
AND e.path IS NOT NULL)",
|
||||
[older_than],
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
Reference in New Issue
Block a user