From 5666166769c968aebd94e74e47ea839477216263 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 01:42:55 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe --- PROGRESS.md | 41 +++ src/db.rs | 253 +++++++++++-- src/feed.rs | 49 +++ src/main.rs | 3 +- src/web.rs | 155 +++++++- web/index.html | 952 +++++++++++++++++++++++++++++++++++-------------- 6 files changed, 1143 insertions(+), 310 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 1ad4bc2..73f3da6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,47 @@ and until now nothing set them. --- +## 2026-09-10 — Phase 3: full-featured UI + +Rewrite of `web/index.html` (~714 lines) plus the backend it needed. + +**New stored metadata**, all of which the real feed carries on all 131 episodes and none of which +was being captured: feed and per-episode artwork (`itunes:image`), duration (`itunes:duration`, +parsed from either raw seconds or a `1:34:09` clock), season/episode numbers, and a playback +`position` so episodes resume. + +**Schema migration.** `Db::open` now runs `migrate()`: `PRAGMA table_info` then `ALTER TABLE ADD +COLUMN` for anything missing, since `CREATE TABLE IF NOT EXISTS` does nothing to an existing table. +Tested on a *copy* of the live database first. Accidentally SIGPIPE'd it partway through (a `head -4` +on the output) which usefully proved it is idempotent and self-heals: the next run added the +remaining columns, 131 entries and 6 downloads intact. + +**New endpoints:** entry filters (all/unread/downloaded/flagged), case-insensitive search over title +and notes, pagination totals, `POST .../position`, `read-all`, `download-latest`, and OPML +import/export over HTTP. + +**UI:** persistent player bar surviving navigation (play/pause, ±15s/30s, 0.8-2.5x speed, volume, +scrubber, all remembered in localStorage), resume playback via `sendBeacon` every 10s, MediaSession +for lock-screen controls, keyboard shortcuts (space, arrows, `/`, Esc), artwork everywhere with +generated-initials fallback, filter tabs, episode search, load-more pagination, live progress bars +off SSE, real modals, toasts, light/dark toggle, and a mobile layout with a collapsing sidebar. + +**Bug found and fixed in review: filtering was broken while searching worked.** `count_entries` +dropped the search clause when no term was given but still bound `?2`; rusqlite rejects a parameter +the statement never mentions -- *"Wrong number of parameters passed to query. Got 2, needed 1"*. So +`?q=...` worked and a plain Unread/Downloaded/Flagged filter 500'd. The clause is now always present +with `?2 = ''` short-circuiting it. Regression test runs all four filters with and without a search +term and asserts the page and the count agree. + +**A non-bug worth recording:** artwork looked broken on the live DB (0/131) while a fresh DB filled +all 131. The parser was fine -- the live DB simply had not been rescanned yet, since backfill happens +through `record_entry`'s update path. After a forced scan: 131/131 image, duration and season. + +Verified live: all four filters return sane totals (131/129/10/0), search works (`session zero` -> 2, +`trunk` -> 10), `/media/1` with a Range header returns 206, index serves 34 KB. `cargo test` 32/32. + +--- + ## 2026-09-10 — Fixed: the UI's Download button downloaded the wrong episodes Reported from the running instance: clicking Download on *Music from a Darkened Room | Session Zero* diff --git a/src/db.rs b/src/db.rs index c675fc2..5b74a77 100644 --- a/src/db.rs +++ b/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 = stmt + .query_map([], |r| r.get::<_, String>(1))? + .collect::>>()?; + 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, + pub image: Option, pub last_checked: Option, pub last_error: Option, 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, + 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, pub link: Option, pub published: Option, pub description: Option, pub read: bool, pub flagged: bool, + pub image: Option, + pub duration: Option, + pub episode: Option, + pub season: Option, + pub position: i64, pub enclosures: Vec, } +/// 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> { + /// `search` matches title and description, case-insensitively. + pub fn entries( + &self, + feed_id: &str, + filter: Filter, + search: Option<&str>, + offset: i64, + limit: i64, + ) -> Result> { 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 { + 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 = 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::>>()?; 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 { + 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 { + 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> { + 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::>>()?) + } + pub fn enclosure(&self, id: i64) -> Result> { 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(); diff --git a/src/feed.rs b/src/feed.rs index d7e68cb..2f94fb6 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -10,6 +10,7 @@ use crate::config::Feed as FeedCfg; pub struct ParsedFeed { pub title: Option, pub ttl_mins: Option, + pub image: Option, pub entries: Vec, } @@ -22,6 +23,12 @@ pub struct Entry { pub description: Option, pub categories: Vec, pub explicit: bool, + /// Episode artwork; falls back to the feed's in the UI. + pub image: Option, + /// Seconds. + pub duration: Option, + pub episode: Option, + pub season: Option, pub enclosures: Vec, } @@ -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; 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 { 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 { + 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::().ok()?; + } + Some(total).filter(|n| *n > 0) +} + /// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead. fn parse_date(s: &str) -> Option { 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"nope").is_err()); diff --git a/src/main.rs b/src/main.rs index e7c86d8..ccfc769 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(); diff --git a/src/web.rs b/src/web.rs index b191fe6..4d8353f 100644 --- a/src/web.rs +++ b/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, + image: Option, folder: Option, keywords: Vec, allow_explicit: bool, @@ -163,6 +168,7 @@ async fn feeds(State(state): State) -> Result>, 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, + #[serde(default)] + q: Option, } fn fifty() -> i64 { 50 } +#[derive(Serialize)] +struct EntryPage { + total: i64, + entries: Vec, +} + async fn entries( State(state): State, Path(id): Path, Query(page): Query, -) -> Result>, ApiError> { - let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?; +) -> Result, 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, + Path((feed_id, guid)): Path<(String, String)>, + Json(body): Json, +) -> Result { + state.ctx.db.set_position(&feed_id, &guid, body.secs)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn read_all( + State(state): State, + Path(id): Path, +) -> Result, 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, + Path(id): Path, + Json(body): Json, +) -> Result, 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) -> Result { + 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, + Json(body): Json, +) -> Result, 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 }))) +} diff --git a/web/index.html b/web/index.html index 0d95c05..49f1168 100644 --- a/web/index.html +++ b/web/index.html @@ -3,313 +3,711 @@ + ipx -
-

ipx

- - - -
-
- -

Pick a feed.

-
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + +
+
+ 0:00 + + 0:00 +
+
+
+ + + +
+
+ + +
+