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:
41
PROGRESS.md
41
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
|
## 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*
|
||||||
|
|||||||
253
src/db.rs
253
src/db.rs
@@ -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
|
||||||
|
FROM entries e
|
||||||
|
WHERE e.feed_id = ?1 AND {} AND {SEARCH}
|
||||||
|
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||||
|
LIMIT ?4 OFFSET ?3",
|
||||||
|
filter.sql()
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let map = |r: &rusqlite::Row| -> rusqlite::Result<EntryRow> {
|
||||||
|
Ok(EntryRow {
|
||||||
|
guid: r.get(0)?,
|
||||||
|
feed_id: r.get(1)?,
|
||||||
|
title: r.get(2)?,
|
||||||
|
link: r.get(3)?,
|
||||||
|
published: r.get(4)?,
|
||||||
|
description: r.get(5)?,
|
||||||
|
read: r.get::<_, i64>(6)? != 0,
|
||||||
|
flagged: r.get::<_, i64>(7)? != 0,
|
||||||
|
image: r.get(8)?,
|
||||||
|
duration: r.get(9)?,
|
||||||
|
episode: r.get(10)?,
|
||||||
|
season: r.get(11)?,
|
||||||
|
position: r.get(12)?,
|
||||||
|
enclosures: vec![],
|
||||||
|
})
|
||||||
|
};
|
||||||
let mut rows: Vec<EntryRow> = stmt
|
let mut rows: Vec<EntryRow> = stmt
|
||||||
.query_map(rusqlite::params![feed_id, offset, limit], |r| {
|
.query_map(rusqlite::params![feed_id, like, offset, limit], map)?
|
||||||
Ok(EntryRow {
|
|
||||||
guid: r.get(0)?,
|
|
||||||
title: r.get(1)?,
|
|
||||||
link: r.get(2)?,
|
|
||||||
published: r.get(3)?,
|
|
||||||
description: r.get(4)?,
|
|
||||||
read: r.get::<_, i64>(5)? != 0,
|
|
||||||
flagged: r.get::<_, i64>(6)? != 0,
|
|
||||||
enclosures: vec![],
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.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();
|
||||||
|
|||||||
49
src/feed.rs
49
src/feed.rs
@@ -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());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
155
src/web.rs
155
src/web.rs
@@ -64,9 +64,13 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
.route("/api/feeds/{id}", 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 })))
|
||||||
|
}
|
||||||
|
|||||||
952
web/index.html
952
web/index.html
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 · ${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>
|
||||||
<div id="settings" hidden></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="list"></div>`;
|
<div class="toolbar">
|
||||||
const list = $('#list');
|
<div class="tabs">
|
||||||
if (!entries.length) { list.innerHTML = '<p class="empty">No entries yet — try Scan now.</p>'; return; }
|
${['unread','all','downloaded','flagged'].map(t=>
|
||||||
for (const e of entries) list.appendChild(entryEl(f, e));
|
`<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, '"')}, ${!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;
|
||||||
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||||
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
|
}
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- player ---------------- */
|
||||||
|
const audio=$('#audio');
|
||||||
|
const player={guid:null,feed:null,entry:null,saveAt:0};
|
||||||
|
|
||||||
|
function play(e){
|
||||||
|
const enc=e.enclosures.find(x=>x.path);
|
||||||
|
if(!enc){ toast('Not downloaded yet',true); return; }
|
||||||
|
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){
|
||||||
function encEl(f, e, x) {
|
if(!('mediaSession' in navigator)) return;
|
||||||
const g = JSON.stringify(e.guid).replace(/"/g, '"');
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
if (x.path) {
|
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
return `<div>
|
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
|
||||||
<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>
|
|
||||||
<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) {
|
|
||||||
await api(`/api/entries/${encodeURIComponent(feedId)}/${encodeURIComponent(guid)}/flags`,
|
|
||||||
{ method: 'POST', body: JSON.stringify({ read }) });
|
|
||||||
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) {
|
|
||||||
await api(`/api/entries/${encodeURIComponent(f.id)}/${encodeURIComponent(e.guid)}/flags`,
|
|
||||||
{ method: 'POST', body: JSON.stringify({ flagged: on }) });
|
|
||||||
e.flagged = on; render(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dl(id) { say('Queued…'); await api(`/api/enclosures/${id}/download`, { method: 'POST' }); }
|
|
||||||
async function delFile(id) {
|
|
||||||
if (!confirm('Delete this file? The episode stays listed and will not be re-downloaded.')) return;
|
|
||||||
await api(`/api/enclosures/${id}`, { method: 'DELETE' });
|
|
||||||
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) + '%';
|
|
||||||
} else if (ev.ev === 'download_done') {
|
|
||||||
say('Downloaded ' + ev.path.split('/').pop());
|
|
||||||
loadEntries(); loadFeeds();
|
|
||||||
} else if (ev.ev === 'feed_done') {
|
|
||||||
say(`${ev.feed}: ${ev.new} new, ${ev.downloaded} downloaded`);
|
|
||||||
loadEntries(); loadFeeds();
|
|
||||||
} else if (ev.ev === 'scan_done') {
|
|
||||||
say('Scan complete.'); loadFeeds();
|
|
||||||
} else if (ev.ev === 'feed_error' || ev.ev === 'download_error') {
|
|
||||||
say('Error: ' + ev.msg);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
if(localStorage.getItem('ipx.theme')) document.documentElement.dataset.theme=localStorage.getItem('ipx.theme');
|
||||||
|
|
||||||
|
/* ---------------- live events ---------------- */
|
||||||
|
let sse;
|
||||||
|
function connect(){
|
||||||
|
sse=new EventSource('/api/events');
|
||||||
|
sse.onmessage=m=>{
|
||||||
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
|
if(ev.ev==='progress'){
|
||||||
|
const pct=ev.total?ev.done/ev.total*100:0;
|
||||||
|
$$('.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user