Full-featured web UI
Rewrites the page around a persistent player (speed, seek, resume, MediaSession, keyboard shortcuts), artwork, filter tabs, episode search, pagination and live progress, with modals and toasts replacing prompt() and a status line. Backend gains the metadata that makes that possible: feed and episode artwork, durations, season/episode numbers and playback position, plus filters, search, totals, mark-all-read, download-latest and OPML over HTTP. Schema changes arrive through a real migration, since CREATE TABLE IF NOT EXISTS does nothing to an installed database. Fixes filtering, which returned 500 whenever no search term was given: the search clause was dropped while its parameter was still bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
253
src/db.rs
253
src/db.rs
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
last_checked INTEGER,
|
||||
@@ -33,6 +34,12 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
first_seen INTEGER NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
flagged INTEGER NOT NULL DEFAULT 0,
|
||||
image TEXT,
|
||||
duration INTEGER,
|
||||
episode INTEGER,
|
||||
season INTEGER,
|
||||
-- Seconds into the audio, so playback resumes where it was left.
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (feed_id, guid)
|
||||
);
|
||||
|
||||
@@ -56,10 +63,35 @@ CREATE TABLE IF NOT EXISTS enclosures (
|
||||
CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
|
||||
";
|
||||
|
||||
/// Adds columns that later versions introduced. CREATE TABLE IF NOT EXISTS does nothing to
|
||||
/// a table that already exists, so an installed database needs them added explicitly.
|
||||
fn migrate(conn: &Connection) -> Result<()> {
|
||||
let wanted: &[(&str, &str, &str)] = &[
|
||||
("feeds", "image", "TEXT"),
|
||||
("entries", "image", "TEXT"),
|
||||
("entries", "duration", "INTEGER"),
|
||||
("entries", "episode", "INTEGER"),
|
||||
("entries", "season", "INTEGER"),
|
||||
("entries", "position", "INTEGER NOT NULL DEFAULT 0"),
|
||||
];
|
||||
for (table, column, ty) in wanted {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
let existing: Vec<String> = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if !existing.iter().any(|c| c == column) {
|
||||
tracing::info!(table, column, "adding column");
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `ipx list` shows next to each configured feed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FeedSummary {
|
||||
pub title: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub last_checked: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
pub entries: i64,
|
||||
@@ -78,6 +110,7 @@ impl Db {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.execute_batch(SCHEMA).context("creating schema")?;
|
||||
migrate(&conn).context("migrating schema")?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
@@ -99,13 +132,14 @@ impl Db {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut sum: FeedSummary = conn
|
||||
.query_row(
|
||||
"SELECT title, last_checked, last_error FROM feeds WHERE id = ?1",
|
||||
"SELECT title, image, 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)?,
|
||||
image: r.get(1)?,
|
||||
last_checked: r.get(2)?,
|
||||
last_error: r.get(3)?,
|
||||
..Default::default()
|
||||
})
|
||||
},
|
||||
@@ -166,11 +200,12 @@ impl Db {
|
||||
etag: Option<&str>,
|
||||
last_modified: Option<&str>,
|
||||
ttl_mins: Option<u64>,
|
||||
image: Option<&str>,
|
||||
) -> 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)
|
||||
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
url = excluded.url,
|
||||
title = coalesce(excluded.title, feeds.title),
|
||||
@@ -178,8 +213,9 @@ impl Db {
|
||||
last_modified = excluded.last_modified,
|
||||
last_checked = excluded.last_checked,
|
||||
ttl_mins = excluded.ttl_mins,
|
||||
image = coalesce(excluded.image, feeds.image),
|
||||
last_error = NULL",
|
||||
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64)],
|
||||
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -213,9 +249,13 @@ impl Db {
|
||||
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()],
|
||||
(feed_id, guid, title, link, published, description, first_seen, read, flagged,
|
||||
image, duration, episode, season)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0, ?8, ?9, ?10, ?11)",
|
||||
rusqlite::params![
|
||||
feed_id, e.guid, e.title, e.link, e.published, e.description, now(),
|
||||
e.image, e.duration, e.episode, e.season
|
||||
],
|
||||
)?;
|
||||
if inserted == 0 {
|
||||
// The SET expressions see the pre-update row, so this compares old vs new.
|
||||
@@ -223,9 +263,16 @@ impl Db {
|
||||
"UPDATE entries SET
|
||||
title = coalesce(?3, title),
|
||||
description = coalesce(?4, description),
|
||||
image = coalesce(?5, image),
|
||||
duration = coalesce(?6, duration),
|
||||
episode = coalesce(?7, episode),
|
||||
season = coalesce(?8, season),
|
||||
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],
|
||||
rusqlite::params![
|
||||
feed_id, e.guid, e.title, e.description,
|
||||
e.image, e.duration, e.episode, e.season
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(inserted == 1)
|
||||
@@ -410,15 +457,60 @@ impl Db {
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct EntryRow {
|
||||
pub guid: String,
|
||||
pub feed_id: String,
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
pub read: bool,
|
||||
pub flagged: bool,
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub season: Option<i64>,
|
||||
pub position: i64,
|
||||
pub enclosures: Vec<EncRow>,
|
||||
}
|
||||
|
||||
/// The search clause. `?2` is referenced unconditionally -- binding a parameter the
|
||||
/// statement does not mention is an error, so an empty needle short-circuits instead.
|
||||
const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2
|
||||
OR lower(coalesce(e.description, '')) LIKE ?2)";
|
||||
|
||||
/// Which slice of a feed the UI is asking for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
All,
|
||||
Unread,
|
||||
Downloaded,
|
||||
Flagged,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"unread" => Self::Unread,
|
||||
"downloaded" => Self::Downloaded,
|
||||
"flagged" => Self::Flagged,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
|
||||
/// The WHERE fragment for this filter. `e` is entries, and the EXISTS subquery is
|
||||
/// correlated against it.
|
||||
fn sql(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "1=1",
|
||||
Self::Unread => "e.read = 0",
|
||||
Self::Flagged => "e.flagged = 1",
|
||||
Self::Downloaded => {
|
||||
"EXISTS (SELECT 1 FROM enclosures x
|
||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EncRow {
|
||||
pub id: i64,
|
||||
@@ -434,27 +526,49 @@ pub struct EncRow {
|
||||
|
||||
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>> {
|
||||
/// `search` matches title and description, case-insensitively.
|
||||
pub fn entries(
|
||||
&self,
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&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 like = search
|
||||
.map(|q| format!("%{}%", q.trim().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
let sql = format!(
|
||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, e.read,
|
||||
e.flagged, e.image, e.duration, e.episode, e.season, e.position
|
||||
FROM entries e
|
||||
WHERE e.feed_id = ?1 AND {} AND {SEARCH}
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||
LIMIT ?4 OFFSET ?3",
|
||||
filter.sql()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let map = |r: &rusqlite::Row| -> rusqlite::Result<EntryRow> {
|
||||
Ok(EntryRow {
|
||||
guid: r.get(0)?,
|
||||
feed_id: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
link: r.get(3)?,
|
||||
published: r.get(4)?,
|
||||
description: r.get(5)?,
|
||||
read: r.get::<_, i64>(6)? != 0,
|
||||
flagged: r.get::<_, i64>(7)? != 0,
|
||||
image: r.get(8)?,
|
||||
duration: r.get(9)?,
|
||||
episode: r.get(10)?,
|
||||
season: r.get(11)?,
|
||||
position: r.get(12)?,
|
||||
enclosures: vec![],
|
||||
})
|
||||
};
|
||||
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![],
|
||||
})
|
||||
})?
|
||||
.query_map(rusqlite::params![feed_id, like, offset, limit], map)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
if rows.is_empty() {
|
||||
@@ -497,6 +611,51 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many entries match, so the UI knows whether there is another page.
|
||||
pub fn count_entries(&self, feed_id: &str, filter: Filter, search: Option<&str>) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
.map(|q| format!("%{}%", q.trim().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
let sql = format!(
|
||||
"SELECT count(*) FROM entries e WHERE e.feed_id = ?1 AND {} AND {SEARCH}",
|
||||
filter.sql()
|
||||
);
|
||||
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
/// Where playback got to, so it resumes there next time.
|
||||
pub fn set_position(&self, feed_id: &str, guid: &str, secs: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE entries SET position = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
rusqlite::params![feed_id, guid, secs.max(0)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks every entry in a feed read, for the "mark all read" button.
|
||||
pub fn mark_all_read(&self, feed_id: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.execute("UPDATE entries SET read = 1 WHERE feed_id = ?1 AND read = 0", [feed_id])?)
|
||||
}
|
||||
|
||||
/// The next N enclosures with no file, newest entry first -- what "download latest"
|
||||
/// queues up.
|
||||
pub fn undownloaded(&self, feed_id: &str, limit: i64) -> Result<Vec<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT x.id FROM enclosures x
|
||||
JOIN entries e ON e.feed_id = x.feed_id AND e.guid = x.guid
|
||||
WHERE x.feed_id = ?1 AND x.path IS NULL AND x.state != 'reaped'
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
Ok(stmt
|
||||
.query_map(rusqlite::params![feed_id, limit], |r| r.get(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
pub fn enclosure(&self, id: i64) -> Result<Option<EncRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
@@ -575,6 +734,42 @@ mod tests {
|
||||
assert_eq!(sum.downloaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_works_with_and_without_a_search_term() {
|
||||
// Regression: the search clause used to be omitted when no term was given, while
|
||||
// ?2 was still bound -- rusqlite rejects a parameter the statement never mentions,
|
||||
// so plain filtering failed with "Wrong number of parameters passed to query".
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen, read, flagged) VALUES
|
||||
('f','a','Alpha dive','notes one',100,0,0),
|
||||
('f','b','Beta', 'notes two',200,1,0),
|
||||
('f','c','Gamma dive','notes three',300,1,1);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1,'f','b','u1','/tmp/b','done');",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
|
||||
// Both paths must run without erroring, and agree with each other.
|
||||
let rows = db.entries("f", f, None, 0, 50).unwrap();
|
||||
let n = db.count_entries("f", f, None).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
|
||||
|
||||
let rows = db.entries("f", f, Some("dive"), 0, 50).unwrap();
|
||||
let n = db.count_entries("f", f, Some("dive")).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
|
||||
}
|
||||
|
||||
assert_eq!(db.count_entries("f", Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_entries("f", Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_entries("f", Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
"search is case-insensitive and covers the description");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user