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:
2026-09-10 01:42:55 +00:00
parent 93b4815d84
commit 5666166769
6 changed files with 1143 additions and 310 deletions

View File

@@ -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 ## 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* Reported from the running instance: clicking Download on *Music from a Darkened Room | Session Zero*

247
src/db.rs
View File

@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
url TEXT NOT NULL, url TEXT NOT NULL,
title TEXT, title TEXT,
image TEXT,
etag TEXT, etag TEXT,
last_modified TEXT, last_modified TEXT,
last_checked INTEGER, last_checked INTEGER,
@@ -33,6 +34,12 @@ CREATE TABLE IF NOT EXISTS entries (
first_seen INTEGER NOT NULL, first_seen INTEGER NOT NULL,
read INTEGER NOT NULL DEFAULT 0, read INTEGER NOT NULL DEFAULT 0,
flagged 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) 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); 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. /// What `ipx list` shows next to each configured feed.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct FeedSummary { pub struct FeedSummary {
pub title: Option<String>, pub title: Option<String>,
pub image: Option<String>,
pub last_checked: Option<i64>, pub last_checked: Option<i64>,
pub last_error: Option<String>, pub last_error: Option<String>,
pub entries: i64, pub entries: i64,
@@ -78,6 +110,7 @@ impl Db {
conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?; conn.pragma_update(None, "busy_timeout", 5000)?;
conn.execute_batch(SCHEMA).context("creating schema")?; conn.execute_batch(SCHEMA).context("creating schema")?;
migrate(&conn).context("migrating schema")?;
Ok(Self { conn: Mutex::new(conn) }) Ok(Self { conn: Mutex::new(conn) })
} }
@@ -99,13 +132,14 @@ impl Db {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn let mut sum: FeedSummary = conn
.query_row( .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], [feed_id],
|r| { |r| {
Ok(FeedSummary { Ok(FeedSummary {
title: r.get(0)?, title: r.get(0)?,
last_checked: r.get(1)?, image: r.get(1)?,
last_error: r.get(2)?, last_checked: r.get(2)?,
last_error: r.get(3)?,
..Default::default() ..Default::default()
}) })
}, },
@@ -166,11 +200,12 @@ impl Db {
etag: Option<&str>, etag: Option<&str>,
last_modified: Option<&str>, last_modified: Option<&str>,
ttl_mins: Option<u64>, ttl_mins: Option<u64>,
image: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error) "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) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
url = excluded.url, url = excluded.url,
title = coalesce(excluded.title, feeds.title), title = coalesce(excluded.title, feeds.title),
@@ -178,8 +213,9 @@ impl Db {
last_modified = excluded.last_modified, last_modified = excluded.last_modified,
last_checked = excluded.last_checked, last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins, ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image),
last_error = NULL", 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(()) Ok(())
} }
@@ -213,9 +249,13 @@ impl Db {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let inserted = conn.execute( let inserted = conn.execute(
"INSERT OR IGNORE INTO entries "INSERT OR IGNORE INTO entries
(feed_id, guid, title, link, published, description, first_seen, read, flagged) (feed_id, guid, title, link, published, description, first_seen, read, flagged,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0)", image, duration, episode, season)
rusqlite::params![feed_id, e.guid, e.title, e.link, e.published, e.description, now()], 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 { if inserted == 0 {
// The SET expressions see the pre-update row, so this compares old vs new. // The SET expressions see the pre-update row, so this compares old vs new.
@@ -223,9 +263,16 @@ impl Db {
"UPDATE entries SET "UPDATE entries SET
title = coalesce(?3, title), title = coalesce(?3, title),
description = coalesce(?4, description), 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 read = CASE WHEN description IS NOT ?4 OR title IS NOT ?3 THEN 0 ELSE read END
WHERE feed_id = ?1 AND guid = ?2", 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) Ok(inserted == 1)
@@ -410,15 +457,60 @@ impl Db {
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
pub struct EntryRow { pub struct EntryRow {
pub guid: String, pub guid: String,
pub feed_id: String,
pub title: Option<String>, pub title: Option<String>,
pub link: Option<String>, pub link: Option<String>,
pub published: Option<i64>, pub published: Option<i64>,
pub description: Option<String>, pub description: Option<String>,
pub read: bool, pub read: bool,
pub flagged: 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>, 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)] #[derive(Debug, Clone, serde::Serialize)]
pub struct EncRow { pub struct EncRow {
pub id: i64, pub id: i64,
@@ -434,27 +526,49 @@ pub struct EncRow {
impl Db { impl Db {
/// One page of a feed's entries, newest first, each with its enclosures attached. /// 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 conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let like = search
"SELECT guid, title, link, published, description, read, flagged .map(|q| format!("%{}%", q.trim().to_lowercase()))
FROM entries WHERE feed_id = ?1 .unwrap_or_default();
ORDER BY coalesce(published, first_seen) DESC, rowid DESC let sql = format!(
LIMIT ?3 OFFSET ?2", "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
let mut rows: Vec<EntryRow> = stmt FROM entries e
.query_map(rusqlite::params![feed_id, offset, limit], |r| { 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 { Ok(EntryRow {
guid: r.get(0)?, guid: r.get(0)?,
title: r.get(1)?, feed_id: r.get(1)?,
link: r.get(2)?, title: r.get(2)?,
published: r.get(3)?, link: r.get(3)?,
description: r.get(4)?, published: r.get(4)?,
read: r.get::<_, i64>(5)? != 0, description: r.get(5)?,
flagged: r.get::<_, i64>(6)? != 0, 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![], enclosures: vec![],
}) })
})? };
let mut rows: Vec<EntryRow> = stmt
.query_map(rusqlite::params![feed_id, like, offset, limit], map)?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
if rows.is_empty() { if rows.is_empty() {
@@ -497,6 +611,51 @@ impl Db {
Ok(rows) 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>> { pub fn enclosure(&self, id: i64) -> Result<Option<EncRow>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
Ok(conn Ok(conn
@@ -575,6 +734,42 @@ mod tests {
assert_eq!(sum.downloaded, 0); 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] #[test]
fn enclosure_url_is_the_dedupe_key() { fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();

View File

@@ -10,6 +10,7 @@ use crate::config::Feed as FeedCfg;
pub struct ParsedFeed { pub struct ParsedFeed {
pub title: Option<String>, pub title: Option<String>,
pub ttl_mins: Option<u64>, pub ttl_mins: Option<u64>,
pub image: Option<String>,
pub entries: Vec<Entry>, pub entries: Vec<Entry>,
} }
@@ -22,6 +23,12 @@ pub struct Entry {
pub description: Option<String>, pub description: Option<String>,
pub categories: Vec<String>, pub categories: Vec<String>,
pub explicit: bool, 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>, pub enclosures: Vec<Enclosure>,
} }
@@ -123,6 +130,7 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.itunes_ext() .itunes_ext()
.and_then(|it| it.explicit()) .and_then(|it| it.explicit())
.is_some_and(is_yes); .is_some_and(is_yes);
let it = item.itunes_ext();
Some(Entry { Some(Entry {
guid, guid,
@@ -138,6 +146,10 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.filter(|c| !c.is_empty() && !c.starts_with("http")) .filter(|c| !c.is_empty() && !c.starts_with("http"))
.collect(), .collect(),
explicit: explicit || entry_explicit, 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, enclosures,
}) })
}) })
@@ -146,6 +158,12 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
ParsedFeed { ParsedFeed {
title: non_empty(Some(ch.title())), title: non_empty(Some(ch.title())),
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()), 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, entries,
} }
} }
@@ -193,6 +211,10 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.map(str::to_owned), .map(str::to_owned),
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(), categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false, explicit: false,
image: None,
duration: None,
episode: None,
season: None,
enclosures, enclosures,
}) })
}) })
@@ -201,6 +223,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
ParsedFeed { ParsedFeed {
title: non_empty(Some(feed.title().as_str())), title: non_empty(Some(feed.title().as_str())),
ttl_mins: None, ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
entries, 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) 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. /// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead.
fn parse_date(s: &str) -> Option<i64> { fn parse_date(s: &str) -> Option<i64> {
let s = s.trim(); 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] #[test]
fn rejects_html_masquerading_as_a_feed() { fn rejects_html_masquerading_as_a_feed() {
assert!(parse(b"<html><body>nope</body></html>").is_err()); assert!(parse(b"<html><body>nope</body></html>").is_err());

View File

@@ -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. /// 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 { for o in outlines {
if let Some(url) = &o.xml_url { if let Some(url) = &o.xml_url {
let title = o.title.clone().unwrap_or_else(|| o.text.clone()); let title = o.title.clone().unwrap_or_else(|| o.text.clone());
@@ -565,6 +565,7 @@ async fn scan_one(
etag.as_deref(), etag.as_deref(),
last_modified.as_deref(), last_modified.as_deref(),
parsed.ttl_mins, parsed.ttl_mins,
parsed.image.as_deref(),
)?; )?;
let mut scan = Scan::default(); let mut scan = Scan::default();

View File

@@ -64,9 +64,13 @@ pub fn router(state: WebState) -> Router {
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries)) .route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .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}/download", post(download_now))
.route("/api/enclosures/{id}", delete(delete_file)) .route("/api/enclosures/{id}", delete(delete_file))
.route("/api/fetch", post(fetch_now)) .route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
@@ -142,6 +146,7 @@ struct FeedRow {
id: String, id: String,
url: String, url: String,
title: Option<String>, title: Option<String>,
image: Option<String>,
folder: Option<String>, folder: Option<String>,
keywords: Vec<String>, keywords: Vec<String>,
allow_explicit: bool, allow_explicit: bool,
@@ -163,6 +168,7 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
id: id.clone(), id: id.clone(),
url: feed.url.clone(), url: feed.url.clone(),
title: s.title, title: s.title,
image: s.image,
folder: feed.folder.clone(), folder: feed.folder.clone(),
keywords: feed.keywords.clone(), keywords: feed.keywords.clone(),
allow_explicit: feed.allow_explicit, allow_explicit: feed.allow_explicit,
@@ -223,25 +229,41 @@ struct Page {
offset: i64, offset: i64,
#[serde(default = "fifty")] #[serde(default = "fifty")]
limit: i64, limit: i64,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
q: Option<String>,
} }
fn fifty() -> i64 { fn fifty() -> i64 {
50 50
} }
#[derive(Serialize)]
struct EntryPage {
total: i64,
entries: Vec<crate::db::EntryRow>,
}
async fn entries( async fn entries(
State(state): State<WebState>, State(state): State<WebState>,
Path(id): Path<String>, Path(id): Path<String>,
Query(page): Query<Page>, Query(page): Query<Page>,
) -> Result<Json<Vec<crate::db::EntryRow>>, ApiError> { ) -> Result<Json<EntryPage>, ApiError> {
let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?; 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. // Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d)); 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)] #[derive(Deserialize)]
@@ -437,3 +459,130 @@ async fn media(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), 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 })))
}

View File

@@ -3,313 +3,711 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>ipx</title> <title>ipx</title>
<style> <style>
:root { :root {
--bg: #14161a; --panel: #1c1f26; --panel2: #23272f; --line: #2e333d; --bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --raise:#262b36;
--fg: #e6e8ec; --dim: #9aa2b1; --accent: #6ea8fe; --good: #5fd08a; --line:#2b313c; --fg:#e8eaf0; --dim:#98a1b3; --faint:#6b7385;
--bad: #f4776a; --accent:#6ea8fe; --accent2:#8b7cf6; --good:#4ade80; --bad:#f87171; --warn:#fbbf24;
} --shadow:0 8px 28px rgba(0,0,0,.45);
* { box-sizing: border-box; } --r:10px;
body { }
margin: 0; background: var(--bg); color: var(--fg); :root[data-theme="light"] {
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; --bg:#f6f7f9; --panel:#fff; --panel2:#f0f2f6; --raise:#e6e9ef;
} --line:#dde1e9; --fg:#12151c; --dim:#5b6474; --faint:#8b93a4;
header { --shadow:0 8px 28px rgba(20,25,40,.12);
display: flex; align-items: center; gap: 12px; padding: 10px 16px; }
background: var(--panel); border-bottom: 1px solid var(--line); *{box-sizing:border-box}
position: sticky; top: 0; z-index: 5; html,body{height:100%}
} body{
header h1 { font-size: 16px; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); } margin:0;background:var(--bg);color:var(--fg);
#status { margin-left: auto; color: var(--dim); font-size: 13px; min-height: 1em; } font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
button { display:grid;grid-template-rows:1fr auto;height:100vh;overflow:hidden;
background: var(--panel2); color: var(--fg); border: 1px solid var(--line); }
border-radius: 6px; padding: 5px 10px; cursor: pointer; font-size: 13px; button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
} a{color:var(--accent)}
button:hover { border-color: var(--accent); } ::-webkit-scrollbar{width:10px;height:10px}
button.primary { background: var(--accent); color: #10131a; border-color: var(--accent); font-weight: 600; } ::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
button.danger:hover { border-color: var(--bad); color: var(--bad); } ::-webkit-scrollbar-track{background:transparent}
main { display: grid; grid-template-columns: 300px 1fr; min-height: calc(100vh - 49px); }
#feeds { background: var(--panel); border-right: 1px solid var(--line); padding: 8px; } /* ---------- shell ---------- */
.feed { #shell{display:grid;grid-template-columns:290px 1fr;min-height:0;overflow:hidden}
padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 2px; #sidebar{
} background:var(--panel);border-right:1px solid var(--line);
.feed:hover { background: var(--panel2); } display:flex;flex-direction:column;min-height:0;
.feed.sel { background: var(--panel2); box-shadow: inset 3px 0 0 var(--accent); } }
.feed .name { display: flex; gap: 8px; align-items: baseline; } .brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.feed .name b { font-weight: 600; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .brand .logo{
.pill { background: var(--accent); color: #10131a; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; } width:26px;height:26px;border-radius:7px;flex:none;
.feed small, .meta { color: var(--dim); font-size: 12px; } background:linear-gradient(135deg,var(--accent),var(--accent2));
.err { color: var(--bad); font-size: 12px; } display:grid;place-items:center;font-weight:800;font-size:13px;color:#0b0e14;
#content { padding: 16px 20px; max-width: 900px; } }
.entry { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; background: var(--panel); } .brand h1{font-size:15px;margin:0;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);flex:1}
.entry.unread { border-left: 3px solid var(--accent); } .iconbtn{
.head { padding: 10px 12px; cursor: pointer; display: flex; gap: 10px; align-items: baseline; } width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
.head h3 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; } color:var(--dim);flex:none;
.body { padding: 0 12px 12px; border-top: 1px solid var(--line); } }
.desc { color: #cfd4dd; font-size: 14px; overflow-wrap: anywhere; } .iconbtn:hover{background:var(--raise);color:var(--fg)}
.desc img { max-width: 100%; height: auto; } .sidetools{display:flex;gap:6px;padding:0 12px 10px}
.desc a { color: var(--accent); } .sidetools button{
audio { width: 100%; margin: 10px 0; } flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; } padding:6px 8px;font-size:12.5px;color:var(--dim);
.bar { height: 4px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 6px; } }
.bar i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; } .sidetools button:hover{border-color:var(--accent);color:var(--fg)}
.state { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); } .searchwrap{padding:0 12px 8px}
.state.done { color: var(--good); } input[type=search],input[type=text],input[type=number],select{
.state.error { color: var(--bad); } width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
form.settings { display: grid; gap: 8px; margin-top: 10px; } border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
form.settings label { display: grid; gap: 3px; font-size: 12px; color: var(--dim); } }
input[type=text], input[type=number] { input:focus,select:focus{outline:0;border-color:var(--accent)}
background: var(--bg); border: 1px solid var(--line); color: var(--fg); #feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
border-radius: 6px; padding: 6px 8px; font-size: 14px; width: 100%; .feed{
} display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
.checks { display: flex; gap: 16px; font-size: 13px; color: var(--fg); } cursor:pointer;margin-bottom:1px;
.checks label { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); } }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 12px; } .feed:hover{background:var(--panel2)}
h2 { font-size: 17px; margin: 0 0 2px; } .feed.sel{background:var(--raise)}
.empty { color: var(--dim); padding: 24px 0; } .art{
@media (max-width: 700px) { border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
main { grid-template-columns: 1fr; } display:grid;place-items:center;color:var(--faint);font-weight:700;overflow:hidden;
#feeds { border-right: 0; border-bottom: 1px solid var(--line); } }
} .feed .art{width:38px;height:38px;font-size:14px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:600;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{color:var(--faint);font-size:11.5px}
.badge{
background:var(--accent);color:#0b0e14;border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:700;flex:none;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* ---------- main ---------- */
#main{overflow-y:auto;min-height:0;scroll-behavior:smooth}
.wrap{max-width:1000px;margin:0 auto;padding:18px 22px 40px}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;margin-top:auto}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#0b0e14;font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
.toolbar{
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- episodes ---------- */
.ep{
display:flex;gap:12px;padding:11px;border-radius:var(--r);
border:1px solid transparent;margin-bottom:3px;position:relative;
}
.ep:hover{background:var(--panel)}
.ep.playing{background:var(--panel);border-color:var(--accent)}
.ep .art{width:52px;height:52px;font-size:16px;cursor:pointer;position:relative}
.ep .art .ovl{
position:absolute;inset:0;display:grid;place-items:center;
background:rgba(0,0,0,.5);opacity:0;transition:opacity .12s;color:#fff;
}
.ep .art:hover .ovl,.ep.playing .art .ovl{opacity:1}
.ep .body{flex:1;min-width:0}
.ep .t{font-weight:600;font-size:14.5px;cursor:pointer;display:block}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:12px;margin-top:2px}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.chip{
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
padding:1px 6px;border-radius:5px;background:var(--raise);color:var(--dim);
}
.chip.done{background:rgba(74,222,128,.14);color:var(--good)}
.chip.error{background:rgba(248,113,113,.14);color:var(--bad)}
.chip.pending{background:rgba(251,191,36,.13);color:var(--warn)}
.chip.new{background:var(--accent);color:#0b0e14}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
.notes{
margin-top:10px;padding:11px 13px;background:var(--panel2);border-radius:9px;
color:var(--dim);font-size:13.5px;overflow-wrap:anywhere;
}
.notes img{max-width:100%;height:auto;border-radius:6px}
.notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0}
.dlbar{height:3px;background:var(--raise);border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px}
/* ---------- player ---------- */
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(0,0,0,.28);
}
#player.on{display:grid}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
#pbtns .iconbtn{width:34px;height:34px}
#pplay{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:#0b0e14}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
background:var(--raise);cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
#pright{display:flex;gap:8px;align-items:center}
#pright select{width:auto;padding:4px 6px;font-size:12px}
#vol{width:78px;flex:none}
/* ---------- overlays ---------- */
#modal{
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
}
.card h3{margin:0 0 14px;font-size:17px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger{display:none}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:280px;z-index:40;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#burger{display:grid}
.fhead .art{width:78px;height:78px;font-size:24px}
.fhead h2{font-size:19px}
#pnow{width:auto;flex:1}
#pnow .txt small,#pright #vol{display:none}
.wrap{padding:14px}
}
</style> </style>
</head> </head>
<body> <body>
<header> <div id="shell">
<h1>ipx</h1> <aside id="sidebar">
<button id="fetchAll">Scan all</button> <div class="brand">
<button id="showAdd">+ Feed</button> <div class="logo">ix</div><h1>ipx</h1>
<span id="status"></span> <button class="iconbtn" id="theme" title="Toggle theme"></button>
</header> </div>
<main> <div class="sidetools">
<nav id="feeds"></nav> <button id="addFeed">+ Feed</button>
<section id="content"><p class="empty">Pick a feed.</p></section> <button id="scanAll">Scan all</button>
</main> <button id="opml" title="Import / export OPML">OPML</button>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>
</aside>
<div id="main"><div class="wrap" id="content"></div></div>
</div>
<div id="player">
<div id="pnow">
<button class="iconbtn" id="burger" title="Feeds"></button>
<div id="partwrap"></div>
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
</div>
<div id="pmid">
<div id="pbtns">
<button class="iconbtn" id="pback" title="Back 15s (←)">↺15</button>
<button class="iconbtn" id="pplay" title="Play/pause (space)"></button>
<button class="iconbtn" id="pfwd" title="Forward 30s (→)">30↻</button>
</div>
<div id="seekrow">
<span id="pcur">0:00</span>
<input type="range" id="seek" min="0" max="1000" value="0">
<span id="pdur">0:00</span>
</div>
</div>
<div id="pright">
<select id="rate" title="Speed">
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
<option value="1.75">1.75×</option><option value="2">2×</option><option value="2.5">2.5×</option>
</select>
<input type="range" id="vol" min="0" max="100" value="100" title="Volume">
<button class="iconbtn" id="pclose" title="Close"></button>
</div>
</div>
<div id="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></div>
<audio id="audio" preload="metadata"></audio>
<script> <script>
const $ = (s, r = document) => r.querySelector(s); 'use strict';
const api = async (url, opts) => { const $ = (s,r=document)=>r.querySelector(s);
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts }); const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
if (!r.ok) throw new Error((await r.text()) || r.status); const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
return r.status === 204 ? null : r.json().catch(() => null);
async function api(url,opts){
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
return r.status===204?null:r.json().catch(()=>null);
}
function toast(msg,bad){
const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity=0;t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
}
const clock = s => {
s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
}; };
const esc = s => (s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const ago = t => {
const when = t => t ? new Date(t * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : ''; if(!t) return 'never';
const mb = n => n ? (n / 1048576).toFixed(1) + ' MB' : ''; const d=(Date.now()/1000)-t;
const say = (m) => { $('#status').textContent = m; }; if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
if(d<86400) return Math.round(d/3600)+'h ago';
if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
};
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
const mb = n => n?(n/1048576).toFixed(0)+' MB':'';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
function artHTML(url,name,cls){
return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'art ${cls||''}',textContent:'${esc(initials(name))}'}))">`
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
}
let feeds = [], current = null, entries = [], open = new Set(); /* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
filter:'unread', q:'', open:new Set(), busy:new Set(),
};
const LIMIT = 50;
async function loadFeeds() { /* ---------------- feeds ---------------- */
feeds = await api('/api/feeds'); async function loadFeeds(keepSel){
const nav = $('#feeds'); S.feeds = await api('/api/feeds');
nav.innerHTML = ''; renderFeeds();
if (!feeds.length) { nav.innerHTML = '<p class="empty" style="padding:10px">No feeds yet.</p>'; return; } if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
for (const f of feeds) { }
const el = document.createElement('div'); function renderFeeds(){
el.className = 'feed' + (current === f.id ? ' sel' : ''); const q=$('#feedFilter').value.trim().toLowerCase();
el.innerHTML = `<div class="name"><b>${esc(f.title || f.id)}</b>` + const list=$('#feedlist'); list.innerHTML='';
(f.unread ? `<span class="pill">${f.unread}</span>` : '') + `</div>` + const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
`<small>${f.entries} entries &middot; ${f.downloaded} downloaded</small>` + if(!shown.length){ list.innerHTML='<p class="empty" style="padding:20px 8px">No feeds.</p>'; return; }
(f.last_error ? `<div class="err">${esc(f.last_error)}</div>` : ''); for(const f of shown){
el.onclick = () => selectFeed(f.id); const el=document.createElement('div');
nav.appendChild(el); el.className='feed'+(S.feed===f.id?' sel':'');
el.innerHTML = artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>${f.entries} eps · ${f.downloaded} saved</small></div>`+
`<span class="badge${f.unread?'':' zero'}">${f.unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); $('#sidebar').classList.remove('open'); };
list.appendChild(el);
} }
} }
function selectFeed(id){
async function selectFeed(id) { S.feed=id; S.offset=0; S.open.clear(); S.q='';
current = id; open.clear(); renderFeeds(); renderFeed(); loadEntries();
await loadFeeds();
await loadEntries();
} }
async function loadEntries() { /* ---------------- feed page ---------------- */
const f = feeds.find(x => x.id === current); function renderFeed(){
if (!f) return; const f=S.feeds.find(x=>x.id===S.feed);
entries = await api(`/api/feeds/${encodeURIComponent(current)}/entries?limit=100`); if(!f){ $('#content').innerHTML='<p class="empty">Add a feed to get started.</p>'; return; }
render(f); $('#content').innerHTML = `
} <div class="fhead">
${artHTML(f.image,f.title||f.id)}
function render(f) { <div class="meta">
const c = $('#content'); <h2>${esc(f.title||f.id)}</h2>
c.innerHTML = ` <div class="sub">${f.entries} episodes · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}</div>
<div class="panel"> ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
<h2>${esc(f.title || f.id)}</h2> <div class="acts">
<div class="meta">${esc(f.url)}</div> <button class="btn primary" data-a="scan">Scan now</button>
<div class="row"> <button class="btn" data-a="dl">Download latest…</button>
<button onclick="scan('${f.id}')">Scan now</button> <button class="btn" data-a="read">Mark all read</button>
<button onclick="toggleSettings()">Settings</button> <button class="btn" data-a="settings">Settings</button>
<button class="danger" onclick="removeFeed('${f.id}')">Unsubscribe</button> <button class="btn danger" data-a="rm">Unsubscribe</button>
</div> </div>
<div id="settings" hidden></div>
</div> </div>
<div id="list"></div>`; </div>
const list = $('#list'); <div class="toolbar">
if (!entries.length) { list.innerHTML = '<p class="empty">No entries yet — try Scan now.</p>'; return; } <div class="tabs">
for (const e of entries) list.appendChild(entryEl(f, e)); ${['unread','all','downloaded','flagged'].map(t=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
</div>
<input type="search" class="grow" id="epSearch" placeholder="Search episodes…" value="${esc(S.q)}">
<span style="color:var(--faint);font-size:12.5px" id="count"></span>
</div>
<div id="eps"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
let t; $('#epSearch').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{S.q=e.target.value;S.offset=0;loadEntries()},250)};
} }
function entryEl(f, e) { async function feedAction(a,f){
const div = document.createElement('div'); if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
div.className = 'entry' + (e.read ? '' : ' unread'); if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); loadEntries(); }
div.dataset.guid = e.guid; if(a==='rm') removeFeed(f);
const isOpen = open.has(e.guid); if(a==='settings') settingsModal(f);
div.innerHTML = ` if(a==='dl') downloadLatestModal(f);
<div class="head">
<h3>${esc(e.title || '(untitled)')}</h3>
<span class="meta">${when(e.published)}</span>
<span title="Keep this episode" style="cursor:pointer">${e.flagged ? '★' : '☆'}</span>
</div>
<div class="body" ${isOpen ? '' : 'hidden'}></div>`;
const head = $('.head', div), body = $('.body', div);
head.querySelector('span[title]').onclick = ev => { ev.stopPropagation(); flag(f, e, !e.flagged); };
head.onclick = () => {
const nowOpen = body.hidden;
body.hidden = !nowOpen;
if (nowOpen) { open.add(e.guid); fillBody(body, f, e); } else { open.delete(e.guid); }
};
if (isOpen) fillBody(body, f, e);
return div;
} }
function fillBody(body, f, e) { /* ---------------- episodes ---------------- */
// description is sanitized server-side with ammonia before it ever gets here async function loadEntries(append){
const encs = e.enclosures.map(x => encEl(f, e, x)).join(''); if(!S.feed) return;
body.innerHTML = `<div class="desc">${e.description || '<em>No show notes.</em>'}</div> const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter});
${encs} if(S.q) p.set('q',S.q);
<div class="row"> const r=await api(`/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
<button onclick="markRead('${f.id}', ${JSON.stringify(e.guid).replace(/"/g, '&quot;')}, ${!e.read})"> S.total=r.total;
Mark ${e.read ? 'unread' : 'read'}</button> S.entries = append ? S.entries.concat(r.entries) : r.entries;
${e.link ? `<a class="meta" href="${esc(e.link)}" target="_blank" rel="noreferrer noopener">Open original</a>` : ''} renderEntries();
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const c=$('#count'); if(c) c.textContent=`${S.total} episode${S.total===1?'':'s'}`;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
if(S.entries.length < S.total){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
box.appendChild(b);
}
}
function epEl(e){
const enc=e.enclosures[0];
const has=!!(enc&&enc.path);
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.open.has(e.guid)?' open':'')+
(player.guid===e.guid?' playing':'');
el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=`
${artHTML(e.image||feedArt(),e.title,'')}
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">
${e.read?'':'<span class="chip new">new</span>'}
${num?`<span>${num}</span><span class="dot"></span>`:''}
<span>${dateOf(e.published)}</span>
${left?'<span class="dot"></span><span>'+left+'</span>':''}
${enc?`<span class="dot"></span><span class="chip ${esc(enc.state)}">${has?'downloaded':esc(enc.state)}</span>`:''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${e.flagged?'<span class="dot"></span><span>★ kept</span>':''}
</div>
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
${enc&&enc.last_error?`<div class="line" style="color:var(--bad)">${esc(enc.last_error)}</div>`:''}
</div>
<div class="rowacts">
${has?`<button class="iconbtn" data-a="play" title="Play">▶</button>`:
(enc?`<button class="iconbtn" data-a="get" title="Download">⤓</button>`:'')}
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
${has?`<button class="iconbtn" data-a="del" title="Delete file">🗑</button>`:''}
<button class="iconbtn" data-a="notes" title="Show notes">≡</button>
</div>`; </div>`;
for (const x of e.enclosures) {
const audio = $(`#audio-${x.id}`, body); const art=$('.art',el);
// Playing something is the clearest signal it has been listened to, and retention if(art&&has) art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
// deletes read episodes before unread ones. if(art&&has) art.onclick=()=>play(e);
if (audio) audio.onplay = () => { if (!e.read) markRead(f.id, e.guid, true, true); }; $('.t',el).onclick=()=>toggleNotes(e,el);
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
if(S.open.has(e.guid)) showNotes(e,el);
return el;
}
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
function toggleNotes(e,el){
if(S.open.has(e.guid)){ S.open.delete(e.guid); const n=$('.notes',el); if(n)n.remove(); el.classList.remove('open'); }
else { S.open.add(e.guid); showNotes(e,el); el.classList.add('open'); }
}
function showNotes(e,el){
if($('.notes',el)) return;
const d=document.createElement('div');
d.className='notes';
// Sanitized server-side with ammonia before it ever reaches the browser.
d.innerHTML=(e.description&&e.description.trim())||'<em>No show notes.</em>';
if(e.link) d.insertAdjacentHTML('beforeend',`<p><a href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original ↗</a></p>`);
$('.body',el).appendChild(d);
}
async function epAction(a,e,el){
const enc=e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e);
if(a==='notes') toggleNotes(e,el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); el.replaceWith(epEl(e)); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); el.replaceWith(epEl(e)); loadFeeds(true); }
if(a==='get'){
if(!enc) return;
S.busy.add(enc.id);
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'episode'));
} }
} if(a==='del'){
if(!confirm('Delete the downloaded file?\n\nThe episode stays listed and will not be downloaded again automatically.')) return;
function encEl(f, e, x) { await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
const g = JSON.stringify(e.guid).replace(/"/g, '&quot;'); toast('Deleted'); loadEntries(); loadFeeds(true);
if (x.path) {
return `<div>
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<div class="row">
<span class="state done">downloaded</span><span class="meta">${mb(x.length)}</span>
<a class="meta" href="/media/${x.id}" download>Save file</a>
<button class="danger" onclick="delFile(${x.id})">Delete file</button>
</div></div>`;
} }
return `<div> }catch(err){ toast(err.message,true); }
<div class="row">
<span class="state ${esc(x.state)}">${esc(x.state)}</span>
<span class="meta">${mb(x.length)}</span>
<button onclick="dl(${x.id})">Download</button>
${x.last_error ? `<span class="err">${esc(x.last_error)}</span>` : ''}
</div>
<div class="bar" id="bar-${x.id}"><i></i></div>
</div>`;
} }
async function markRead(feedId, guid, read, quiet) { /* ---------------- player ---------------- */
await api(`/api/entries/${encodeURIComponent(feedId)}/${encodeURIComponent(guid)}/flags`, const audio=$('#audio');
{ method: 'POST', body: JSON.stringify({ read }) }); const player={guid:null,feed:null,entry:null,saveAt:0};
const e = entries.find(x => x.guid === guid); if (e) e.read = read;
if (!quiet) { const f = feeds.find(x => x.id === current); render(f); }
loadFeeds();
}
async function flag(f, e, on) { function play(e){
await api(`/api/entries/${encodeURIComponent(f.id)}/${encodeURIComponent(e.guid)}/flags`, const enc=e.enclosures.find(x=>x.path);
{ method: 'POST', body: JSON.stringify({ flagged: on }) }); if(!enc){ toast('Not downloaded yet',true); return; }
e.flagged = on; render(f); const resuming = player.guid===e.guid;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e;
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
$('#partwrap').innerHTML=artHTML(e.image||feedArt(),e.title);
$('#ptitle').textContent=e.title||'(untitled)';
const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on');
mediaSession(e,f);
// Playing it is the clearest signal it has been listened to; retention deletes
// read episodes before unread ones.
if(!e.read){ e.read=true; api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)); }
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries();
} }
function mediaSession(e,f){
async function dl(id) { say('Queued…'); await api(`/api/enclosures/${id}/download`, { method: 'POST' }); } if(!('mediaSession' in navigator)) return;
async function delFile(id) { navigator.mediaSession.metadata=new MediaMetadata({
if (!confirm('Delete this file? The episode stays listed and will not be re-downloaded.')) return; title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
await api(`/api/enclosures/${id}`, { method: 'DELETE' }); artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
loadEntries(); loadFeeds();
}
async function scan(id) { say('Scanning…'); await api('/api/fetch', { method: 'POST', body: JSON.stringify({ feed: id, force: true }) }); }
async function removeFeed(id) {
if (!confirm(`Unsubscribe from ${id}? Downloads and history are kept.`)) return;
await api(`/api/feeds/${encodeURIComponent(id)}`, { method: 'DELETE' });
current = null; $('#content').innerHTML = '<p class="empty">Pick a feed.</p>'; loadFeeds();
}
function toggleSettings() {
const box = $('#settings'), f = feeds.find(x => x.id === current);
box.hidden = !box.hidden;
if (box.hidden) return;
box.innerHTML = `<form class="settings" onsubmit="return saveSettings(event)">
<label>Folder<input type="text" name="folder" value="${esc(f.folder || '')}" placeholder="${esc(f.title || f.id)}"></label>
<label>Keywords (comma separated; leave empty to take everything)
<input type="text" name="keywords" value="${esc(f.keywords.join(', '))}"></label>
<label>Max new downloads per scan (blank = no limit)
<input type="number" name="max" min="0" value="${f.max_new_per_check ?? ''}"></label>
<div class="checks">
<label><input type="checkbox" name="explicit" ${f.allow_explicit ? 'checked' : ''}> Allow explicit</label>
<label><input type="checkbox" name="auto" ${f.auto_download ? 'checked' : ''}> Auto download</label>
</div>
<div class="row"><button class="primary" type="submit">Save</button></div>
</form>`;
}
async function saveSettings(ev) {
ev.preventDefault();
const d = new FormData(ev.target);
const max = d.get('max');
await api(`/api/feeds/${encodeURIComponent(current)}`, {
method: 'PATCH',
body: JSON.stringify({
folder: d.get('folder').trim() || null,
keywords: d.get('keywords').split(',').map(s => s.trim()).filter(Boolean),
max_new_per_check: max === '' ? null : Number(max),
allow_explicit: d.get('explicit') === 'on',
auto_download: d.get('auto') === 'on',
}),
}); });
say('Saved — applies to the next scan, no restart needed.'); const h={play:()=>audio.play(),pause:()=>audio.pause(),
await loadFeeds(); seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
const f = feeds.find(x => x.id === current); for(const k in h){ try{navigator.mediaSession.setActionHandler(k,h[k])}catch{} }
render(f); }
return false; audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Persist roughly every 10s so a reload resumes where you were.
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
});
function savePos(){
if(!player.guid) return;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();$('#pplay').textContent='▶'});
audio.addEventListener('play',()=>$('#pplay').textContent='❚❚');
audio.addEventListener('pause',()=>$('#pplay').textContent='▶');
window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');renderEntries()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=v/100}
})();
document.addEventListener('keydown',ev=>{
if(/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) return;
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(ev.key==='Escape'){closeModal();$('#sidebar').classList.remove('open')}
});
/* ---------------- modals ---------------- */
function openModal(html){ $('#modalCard').innerHTML=html; $('#modal').classList.add('on'); }
function closeModal(){ $('#modal').classList.remove('on'); }
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"></div>
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only episodes matching a keyword are downloaded.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus();
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true;
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').textContent='Add feed'; $('#nsave').disabled=false; }
};
};
function settingsModal(f){
openModal(`<h3>${esc(f.title||f.id)}</h3>
<div class="field"><label>Download folder</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"></div>
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank means no limit. The rest wait for the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new episodes automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow episodes marked explicit</label>
<div class="field"><label>Feed URL</label><span class="hint" style="overflow-wrap:anywhere">${esc(f.url)}</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`);
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({
folder:$('#sfolder').value.trim()||null,
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})});
closeModal(); toast('Saved — applies on the next scan');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
};
} }
$('#fetchAll').onclick = () => { say('Scanning all feeds…'); api('/api/fetch', { method: 'POST', body: JSON.stringify({ force: true }) }); }; function downloadLatestModal(f){
$('#showAdd').onclick = async () => { openModal(`<h3>Download latest episodes</h3>
const url = prompt('Feed URL'); <div class="field"><label>How many of the newest undownloaded episodes?</label>
if (!url) return; <input type="number" id="dcount" min="1" max="100" value="5">
say('Adding…'); <span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
try { const r = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url }) }); <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
say(r.existing ? `Already subscribed as ${r.id}` : `Added ${r.id}`); <button class="btn primary" id="dgo">Download</button></div>`);
await loadFeeds(); selectFeed(r.id); $('#dgo').onclick=async()=>{
} catch (e) { say('Failed: ' + e.message); } const n=Number($('#dcount').value)||5;
try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} episode${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); }
};
}
function removeFeed(f){
openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn danger" id="rgo">Unsubscribe</button></div>`);
$('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed();
};
}
$('#opml').onclick=()=>{
openModal(`<h3>OPML</h3>
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
<div class="cardacts" style="justify-content:flex-start">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a>
</div>
<div class="field" style="margin-top:16px"><label>Import: paste OPML</label>
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="oimp">Import</button></div>`);
$('#oimp').onclick=async()=>{
try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml:$('#opmlText').value})});
closeModal(); toast(`Imported ${r.added} feed(s)`); loadFeeds(true);
}catch(e){ toast(e.message,true); }
};
}; };
// Live events from the same broadcast bus the socket clients read. $('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
const sse = new EventSource('/api/events'); $('#feedFilter').oninput=renderFeeds;
sse.onmessage = m => { $('#burger').onclick=()=>$('#sidebar').classList.toggle('open');
const ev = JSON.parse(m.data); $('#theme').onclick=()=>{
if (ev.ev === 'progress') { const cur=document.documentElement.dataset.theme==='light'?'dark':'light';
say(`${ev.file}: ${((ev.done / (ev.total || ev.done)) * 100).toFixed(0)}%`); document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);
const bars = document.querySelectorAll('.bar i'); };
for (const b of bars) if (b.parentElement.id) b.style.width = ((ev.done / (ev.total || 1)) * 100) + '%'; if(localStorage.getItem('ipx.theme')) document.documentElement.dataset.theme=localStorage.getItem('ipx.theme');
} else if (ev.ev === 'download_done') {
say('Downloaded ' + ev.path.split('/').pop()); /* ---------------- live events ---------------- */
loadEntries(); loadFeeds(); let sse;
} else if (ev.ev === 'feed_done') { function connect(){
say(`${ev.feed}: ${ev.new} new, ${ev.downloaded} downloaded`); sse=new EventSource('/api/events');
loadEntries(); loadFeeds(); sse.onmessage=m=>{
} else if (ev.ev === 'scan_done') { let ev; try{ ev=JSON.parse(m.data) }catch{ return }
say('Scan complete.'); loadFeeds(); if(ev.ev==='progress'){
} else if (ev.ev === 'feed_error' || ev.ev === 'download_error') { const pct=ev.total?ev.done/ev.total*100:0;
say('Error: ' + ev.msg); $$('.dlbar').forEach(b=>{ /* progress applies to whatever is downloading now */
if(b.dataset.bar) b.firstElementChild.style.width=pct+'%';
});
$('#count') && ($('#count').textContent=`downloading ${ev.file}${pct.toFixed(0)}%`);
} }
}; else if(ev.ev==='download_done'){ toast('Downloaded '+ev.path.split('/').pop()); loadEntries(); loadFeeds(true); }
else if(ev.ev==='download_error'){ toast('Download failed: '+ev.msg,true); loadEntries(); }
else if(ev.ev==='feed_done'){ if(ev.new) toast(`${ev.feed}: ${ev.new} new`); loadFeeds(true); if(ev.feed===S.feed) loadEntries(); }
else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); loadFeeds(true); }
else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}
connect();
loadFeeds(); loadFeeds();
</script> </script>
</body> </body>