Step 3: feed fetch and parse

Conditional GET plus an RSS-first, Atom-fallback parser normalising both
into one entry model, with iTunes explicit and ttl handling carried over
from the Python. Enclosures are recorded as pending; nothing downloads yet.

GUID falls back guid -> link -> enclosure url -> title rather than hashing
the description as the original did.

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 20:34:09 +00:00
parent a13f19136c
commit ab2583fc64
6 changed files with 663 additions and 15 deletions

122
src/db.rs
View File

@@ -121,6 +121,128 @@ impl Db {
}
}
/// Everything `fetch` needs to decide whether to poll a feed, and how.
#[derive(Debug, Default)]
pub struct HttpState {
pub etag: Option<String>,
pub last_modified: Option<String>,
pub last_checked: Option<i64>,
pub ttl_mins: Option<u64>,
}
impl Db {
pub fn http_state(&self, feed_id: &str) -> Result<HttpState> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row(
"SELECT etag, last_modified, last_checked, ttl_mins FROM feeds WHERE id = ?1",
[feed_id],
|r| {
Ok(HttpState {
etag: r.get(0)?,
last_modified: r.get(1)?,
last_checked: r.get(2)?,
ttl_mins: r.get::<_, Option<i64>>(3)?.map(|t| t.max(0) as u64),
})
},
)
.optional()?
.unwrap_or_default())
}
/// Upsert after a successful poll. Clears any previous error.
pub fn record_feed(
&self,
feed_id: &str,
url: &str,
title: Option<&str>,
etag: Option<&str>,
last_modified: Option<&str>,
ttl_mins: Option<u64>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
ON CONFLICT(id) DO UPDATE SET
url = excluded.url,
title = coalesce(excluded.title, feeds.title),
etag = excluded.etag,
last_modified = excluded.last_modified,
last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins,
last_error = NULL",
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64)],
)?;
Ok(())
}
/// 304, or any other poll that produced no new data: only the clock moves.
pub fn touch_feed(&self, feed_id: &str, url: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = NULL",
rusqlite::params![feed_id, url, now()],
)?;
Ok(())
}
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error",
rusqlite::params![feed_id, url, now(), msg],
)?;
Ok(())
}
/// Returns true when this entry had not been seen before.
///
/// A changed description or title flips `read` back to 0, which is what the original's
/// textDiff dance was ultimately for -- minus the diff markup, which the UI can do.
pub fn record_entry(&self, feed_id: &str, e: &crate::feed::Entry) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let inserted = conn.execute(
"INSERT OR IGNORE INTO entries
(feed_id, guid, title, link, published, description, first_seen, read, flagged)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0)",
rusqlite::params![feed_id, e.guid, e.title, e.link, e.published, e.description, now()],
)?;
if inserted == 0 {
// The SET expressions see the pre-update row, so this compares old vs new.
conn.execute(
"UPDATE entries SET
title = coalesce(?3, title),
description = coalesce(?4, description),
read = CASE WHEN description IS NOT ?4 OR title IS NOT ?3 THEN 0 ELSE read END
WHERE feed_id = ?1 AND guid = ?2",
rusqlite::params![feed_id, e.guid, e.title, e.description],
)?;
}
Ok(inserted == 1)
}
/// Returns true when this enclosure URL is new. False means we have downloaded it
/// before, or deliberately reaped it -- either way it is not fetched again.
pub fn record_enclosure(
&self,
feed_id: &str,
guid: &str,
enc: &crate::feed::Enclosure,
) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let inserted = conn.execute(
"INSERT OR IGNORE INTO enclosures (feed_id, guid, url, mime, length, state)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending')",
rusqlite::params![feed_id, guid, enc.url, enc.mime, enc.length],
)?;
Ok(inserted == 1)
}
}
/// Unix seconds. Everything time-shaped in the DB is stored this way.
pub fn now() -> i64 {
std::time::SystemTime::now()