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();
|
||||
|
||||
49
src/feed.rs
49
src/feed.rs
@@ -10,6 +10,7 @@ use crate::config::Feed as FeedCfg;
|
||||
pub struct ParsedFeed {
|
||||
pub title: Option<String>,
|
||||
pub ttl_mins: Option<u64>,
|
||||
pub image: Option<String>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
@@ -22,6 +23,12 @@ pub struct Entry {
|
||||
pub description: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub explicit: bool,
|
||||
/// Episode artwork; falls back to the feed's in the UI.
|
||||
pub image: Option<String>,
|
||||
/// Seconds.
|
||||
pub duration: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub season: Option<i64>,
|
||||
pub enclosures: Vec<Enclosure>,
|
||||
}
|
||||
|
||||
@@ -123,6 +130,7 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
|
||||
.itunes_ext()
|
||||
.and_then(|it| it.explicit())
|
||||
.is_some_and(is_yes);
|
||||
let it = item.itunes_ext();
|
||||
|
||||
Some(Entry {
|
||||
guid,
|
||||
@@ -138,6 +146,10 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
|
||||
.filter(|c| !c.is_empty() && !c.starts_with("http"))
|
||||
.collect(),
|
||||
explicit: explicit || entry_explicit,
|
||||
image: it.and_then(|i| i.image()).map(str::to_owned),
|
||||
duration: it.and_then(|i| i.duration()).and_then(parse_duration),
|
||||
episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()),
|
||||
season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()),
|
||||
enclosures,
|
||||
})
|
||||
})
|
||||
@@ -146,6 +158,12 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
|
||||
ParsedFeed {
|
||||
title: non_empty(Some(ch.title())),
|
||||
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
||||
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
||||
image: ch
|
||||
.itunes_ext()
|
||||
.and_then(|i| i.image())
|
||||
.map(str::to_owned)
|
||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
@@ -193,6 +211,10 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
.map(str::to_owned),
|
||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||
explicit: false,
|
||||
image: None,
|
||||
duration: None,
|
||||
episode: None,
|
||||
season: None,
|
||||
enclosures,
|
||||
})
|
||||
})
|
||||
@@ -201,6 +223,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
ParsedFeed {
|
||||
title: non_empty(Some(feed.title().as_str())),
|
||||
ttl_mins: None,
|
||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
@@ -230,6 +253,22 @@ fn non_empty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
|
||||
fn parse_duration(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !s.contains(':') {
|
||||
return s.parse().ok().filter(|n| *n > 0);
|
||||
}
|
||||
let mut total: i64 = 0;
|
||||
for part in s.split(':') {
|
||||
total = total * 60 + part.trim().parse::<i64>().ok()?;
|
||||
}
|
||||
Some(total).filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
/// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead.
|
||||
fn parse_date(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
@@ -312,6 +351,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_parse_from_seconds_or_a_clock() {
|
||||
assert_eq!(parse_duration("5649"), Some(5649));
|
||||
assert_eq!(parse_duration("23:45"), Some(1425));
|
||||
assert_eq!(parse_duration("1:34:09"), Some(5649));
|
||||
assert_eq!(parse_duration("0"), None, "zero is not a duration");
|
||||
assert_eq!(parse_duration(""), None);
|
||||
assert_eq!(parse_duration("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_html_masquerading_as_a_feed() {
|
||||
assert!(parse(b"<html><body>nope</body></html>").is_err());
|
||||
|
||||
@@ -401,7 +401,7 @@ async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path)
|
||||
}
|
||||
|
||||
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
|
||||
fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
|
||||
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
|
||||
for o in outlines {
|
||||
if let Some(url) = &o.xml_url {
|
||||
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
|
||||
@@ -565,6 +565,7 @@ async fn scan_one(
|
||||
etag.as_deref(),
|
||||
last_modified.as_deref(),
|
||||
parsed.ttl_mins,
|
||||
parsed.image.as_deref(),
|
||||
)?;
|
||||
|
||||
let mut scan = Scan::default();
|
||||
|
||||
155
src/web.rs
155
src/web.rs
@@ -64,9 +64,13 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||
.route("/api/feeds/{id}/read-all", post(read_all))
|
||||
.route("/api/feeds/{id}/download-latest", post(download_latest))
|
||||
.route("/api/enclosures/{id}/download", post(download_now))
|
||||
.route("/api/enclosures/{id}", delete(delete_file))
|
||||
.route("/api/fetch", post(fetch_now))
|
||||
.route("/api/opml", get(export_opml).post(import_opml))
|
||||
.route("/api/events", get(events))
|
||||
.route("/media/{id}", get(media))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
@@ -142,6 +146,7 @@ struct FeedRow {
|
||||
id: String,
|
||||
url: String,
|
||||
title: Option<String>,
|
||||
image: Option<String>,
|
||||
folder: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
allow_explicit: bool,
|
||||
@@ -163,6 +168,7 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
|
||||
id: id.clone(),
|
||||
url: feed.url.clone(),
|
||||
title: s.title,
|
||||
image: s.image,
|
||||
folder: feed.folder.clone(),
|
||||
keywords: feed.keywords.clone(),
|
||||
allow_explicit: feed.allow_explicit,
|
||||
@@ -223,25 +229,41 @@ struct Page {
|
||||
offset: i64,
|
||||
#[serde(default = "fifty")]
|
||||
limit: i64,
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
fn fifty() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EntryPage {
|
||||
total: i64,
|
||||
entries: Vec<crate::db::EntryRow>,
|
||||
}
|
||||
|
||||
async fn entries(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<String>,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<Vec<crate::db::EntryRow>>, ApiError> {
|
||||
let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?;
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
let mut rows = state
|
||||
.ctx
|
||||
.db
|
||||
.entries(&id, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||
for row in &mut rows {
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(ammonia::clean(d));
|
||||
}
|
||||
}
|
||||
Ok(Json(rows))
|
||||
let total = state.ctx.db.count_entries(&id, filter, search)?;
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -437,3 +459,130 @@ async fn media(
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Position {
|
||||
secs: i64,
|
||||
}
|
||||
|
||||
async fn set_position(
|
||||
State(state): State<WebState>,
|
||||
Path((feed_id, guid)): Path<(String, String)>,
|
||||
Json(body): Json<Position>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state.ctx.db.set_position(&feed_id, &guid, body.secs)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn read_all(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let n = state.ctx.db.mark_all_read(&id)?;
|
||||
Ok(Json(serde_json::json!({ "marked": n })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HowMany {
|
||||
#[serde(default = "five")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn five() -> i64 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Queues the newest N undownloaded episodes, each as its own explicit Download command so
|
||||
/// none of them is subject to the per-scan cap.
|
||||
async fn download_latest(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<HowMany>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
|
||||
for enc in &ids {
|
||||
state.ctx.db.requeue(*enc)?;
|
||||
state
|
||||
.cmds
|
||||
.send(Command::Download { enclosure: *enc })
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "queued": ids.len() })))
|
||||
}
|
||||
|
||||
/// Subscriptions as OPML, so they can move to another podcast app.
|
||||
async fn export_opml(State(state): State<WebState>) -> Result<Response, ApiError> {
|
||||
let cfg = state.ctx.cfg();
|
||||
let mut doc = opml::OPML {
|
||||
head: Some(opml::Head {
|
||||
title: Some("ipx subscriptions".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
for (id, feed) in &cfg.feeds {
|
||||
let title = state
|
||||
.ctx
|
||||
.db
|
||||
.feed_summary(id)
|
||||
.ok()
|
||||
.and_then(|s| s.title)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
doc.add_feed(&title, &feed.url);
|
||||
}
|
||||
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/x-opml; charset=utf-8"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"ipx-subscriptions.opml\"",
|
||||
),
|
||||
],
|
||||
xml,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpmlBody {
|
||||
xml: String,
|
||||
}
|
||||
|
||||
async fn import_opml(
|
||||
State(state): State<WebState>,
|
||||
Json(body): Json<OpmlBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let doc = opml::OPML::from_str(&body.xml)
|
||||
.map_err(|e| anyhow::anyhow!("that does not parse as OPML: {e}"))?;
|
||||
let mut found = vec![];
|
||||
crate::collect_outlines(&doc.body.outlines, &mut found);
|
||||
|
||||
let mut cfg = (*state.ctx.cfg()).clone();
|
||||
let mut added = 0;
|
||||
for (title, url) in found {
|
||||
if cfg.feeds.values().any(|f| f.url == url) {
|
||||
continue;
|
||||
}
|
||||
let id = crate::config::unique_slug(&title, &cfg.feeds);
|
||||
cfg.feeds.insert(
|
||||
id,
|
||||
crate::config::Feed {
|
||||
url,
|
||||
folder: None,
|
||||
keywords: vec![],
|
||||
allow_explicit: false,
|
||||
auto_download: true,
|
||||
max_new_per_check: None,
|
||||
username: None,
|
||||
password: None,
|
||||
password_env: None,
|
||||
},
|
||||
);
|
||||
added += 1;
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
Ok(Json(serde_json::json!({ "added": added })))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user