Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus directly: browse feeds, read show notes, play with seeking, download and delete files, mark read/flag, and edit feed settings. Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI edits apply without a daemon restart. Access is a shared token minted from /dev/urandom, carried in a cookie because an <audio> element cannot send headers. Show notes are untrusted feed HTML and are sanitized with ammonia server-side. read/flagged finally have a writer, which retention has needed since it started ordering by them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
155
src/db.rs
155
src/db.rs
@@ -386,6 +386,15 @@ impl Db {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn unread_count(&self, feed_id: &str) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT count(*) FROM entries WHERE feed_id = ?1 AND read = 0",
|
||||
[feed_id],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// (pending, downloaded) across all feeds, for the status command.
|
||||
pub fn counts(&self) -> Result<(i64, i64)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -396,6 +405,152 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// An entry plus its enclosures, for the web UI.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct EntryRow {
|
||||
pub guid: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
pub read: bool,
|
||||
pub flagged: bool,
|
||||
pub enclosures: Vec<EncRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EncRow {
|
||||
pub id: i64,
|
||||
pub feed_id: String,
|
||||
pub guid: String,
|
||||
pub url: String,
|
||||
pub mime: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
pub path: Option<String>,
|
||||
pub state: String,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// One page of a feed's entries, newest first, each with its enclosures attached.
|
||||
pub fn entries(&self, feed_id: &str, offset: i64, limit: i64) -> Result<Vec<EntryRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT guid, title, link, published, description, read, flagged
|
||||
FROM entries WHERE feed_id = ?1
|
||||
ORDER BY coalesce(published, first_seen) DESC, rowid DESC
|
||||
LIMIT ?3 OFFSET ?2",
|
||||
)?;
|
||||
let mut rows: Vec<EntryRow> = stmt
|
||||
.query_map(rusqlite::params![feed_id, offset, limit], |r| {
|
||||
Ok(EntryRow {
|
||||
guid: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
link: r.get(2)?,
|
||||
published: r.get(3)?,
|
||||
description: r.get(4)?,
|
||||
read: r.get::<_, i64>(5)? != 0,
|
||||
flagged: r.get::<_, i64>(6)? != 0,
|
||||
enclosures: vec![],
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// Only the guids on this page, so a feed with thousands of entries stays cheap.
|
||||
let placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
|
||||
let sql = format!(
|
||||
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
||||
FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id"
|
||||
);
|
||||
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1);
|
||||
params.push(&feed_id);
|
||||
for row in &rows {
|
||||
params.push(&row.guid);
|
||||
}
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let encs = stmt
|
||||
.query_map(params.as_slice(), |r| {
|
||||
Ok(EncRow {
|
||||
id: r.get(0)?,
|
||||
feed_id: r.get(1)?,
|
||||
guid: r.get(2)?,
|
||||
url: r.get(3)?,
|
||||
mime: r.get(4)?,
|
||||
length: r.get(5)?,
|
||||
path: r.get(6)?,
|
||||
state: r.get(7)?,
|
||||
last_error: r.get(8)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
for enc in encs {
|
||||
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) {
|
||||
row.enclosures.push(enc);
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn enclosure(&self, id: i64) -> Result<Option<EncRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
||||
FROM enclosures WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
Ok(EncRow {
|
||||
id: r.get(0)?,
|
||||
feed_id: r.get(1)?,
|
||||
guid: r.get(2)?,
|
||||
url: r.get(3)?,
|
||||
mime: r.get(4)?,
|
||||
length: r.get(5)?,
|
||||
path: r.get(6)?,
|
||||
state: r.get(7)?,
|
||||
last_error: r.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// `read` and `flagged` finally get a writer: retention orders by them.
|
||||
pub fn set_entry_flag(&self, feed_id: &str, guid: &str, field: EntryFlag, on: bool) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = match field {
|
||||
EntryFlag::Read => "UPDATE entries SET read = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
EntryFlag::Flagged => "UPDATE entries SET flagged = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
};
|
||||
conn.execute(sql, rusqlite::params![feed_id, guid, on as i64])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts an enclosure back in the queue so the next scan picks it up. This is how a
|
||||
/// `skipped` verdict (from a filter that has since been changed) gets revisited.
|
||||
pub fn requeue(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE enclosures SET state = 'pending', last_error = NULL
|
||||
WHERE id = ?1 AND path IS NULL",
|
||||
[id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum EntryFlag {
|
||||
Read,
|
||||
Flagged,
|
||||
}
|
||||
|
||||
/// 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