Full-featured web UI

Rewrites the page around a persistent player (speed, seek, resume,
MediaSession, keyboard shortcuts), artwork, filter tabs, episode search,
pagination and live progress, with modals and toasts replacing prompt()
and a status line.

Backend gains the metadata that makes that possible: feed and episode
artwork, durations, season/episode numbers and playback position, plus
filters, search, totals, mark-all-read, download-latest and OPML over
HTTP. Schema changes arrive through a real migration, since CREATE TABLE
IF NOT EXISTS does nothing to an installed database.

Fixes filtering, which returned 500 whenever no search term was given:
the search clause was dropped while its parameter was still bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-10 01:42:55 +00:00
parent 93b4815d84
commit 5666166769
6 changed files with 1143 additions and 310 deletions

253
src/db.rs
View File

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

View File

@@ -10,6 +10,7 @@ use crate::config::Feed as FeedCfg;
pub struct ParsedFeed {
pub title: Option<String>,
pub ttl_mins: Option<u64>,
pub image: Option<String>,
pub entries: Vec<Entry>,
}
@@ -22,6 +23,12 @@ pub struct Entry {
pub description: Option<String>,
pub categories: Vec<String>,
pub explicit: bool,
/// Episode artwork; falls back to the feed's in the UI.
pub image: Option<String>,
/// Seconds.
pub duration: Option<i64>,
pub episode: Option<i64>,
pub season: Option<i64>,
pub enclosures: Vec<Enclosure>,
}
@@ -123,6 +130,7 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.itunes_ext()
.and_then(|it| it.explicit())
.is_some_and(is_yes);
let it = item.itunes_ext();
Some(Entry {
guid,
@@ -138,6 +146,10 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
.filter(|c| !c.is_empty() && !c.starts_with("http"))
.collect(),
explicit: explicit || entry_explicit,
image: it.and_then(|i| i.image()).map(str::to_owned),
duration: it.and_then(|i| i.duration()).and_then(parse_duration),
episode: it.and_then(|i| i.episode()).and_then(|e| e.trim().parse().ok()),
season: it.and_then(|i| i.season()).and_then(|e| e.trim().parse().ok()),
enclosures,
})
})
@@ -146,6 +158,12 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
ParsedFeed {
title: non_empty(Some(ch.title())),
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
image: ch
.itunes_ext()
.and_then(|i| i.image())
.map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())),
entries,
}
}
@@ -193,6 +211,10 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.map(str::to_owned),
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false,
image: None,
duration: None,
episode: None,
season: None,
enclosures,
})
})
@@ -201,6 +223,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
ParsedFeed {
title: non_empty(Some(feed.title().as_str())),
ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
entries,
}
}
@@ -230,6 +253,22 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
}
/// itunes:duration is either plain seconds ("5649") or a clock ("1:34:09", "23:45").
fn parse_duration(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
if !s.contains(':') {
return s.parse().ok().filter(|n| *n > 0);
}
let mut total: i64 = 0;
for part in s.split(':') {
total = total * 60 + part.trim().parse::<i64>().ok()?;
}
Some(total).filter(|n| *n > 0)
}
/// RSS pubDate is RFC 2822; some feeds ship RFC 3339 instead.
fn parse_date(s: &str) -> Option<i64> {
let s = s.trim();
@@ -312,6 +351,16 @@ mod tests {
);
}
#[test]
fn durations_parse_from_seconds_or_a_clock() {
assert_eq!(parse_duration("5649"), Some(5649));
assert_eq!(parse_duration("23:45"), Some(1425));
assert_eq!(parse_duration("1:34:09"), Some(5649));
assert_eq!(parse_duration("0"), None, "zero is not a duration");
assert_eq!(parse_duration(""), None);
assert_eq!(parse_duration("garbage"), None);
}
#[test]
fn rejects_html_masquerading_as_a_feed() {
assert!(parse(b"<html><body>nope</body></html>").is_err());

View File

@@ -401,7 +401,7 @@ async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path)
}
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
for o in outlines {
if let Some(url) = &o.xml_url {
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
@@ -565,6 +565,7 @@ async fn scan_one(
etag.as_deref(),
last_modified.as_deref(),
parsed.ttl_mins,
parsed.image.as_deref(),
)?;
let mut scan = Scan::default();

View File

@@ -64,9 +64,13 @@ pub fn router(state: WebState) -> Router {
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all))
.route("/api/feeds/{id}/download-latest", post(download_latest))
.route("/api/enclosures/{id}/download", post(download_now))
.route("/api/enclosures/{id}", delete(delete_file))
.route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml))
.route("/api/events", get(events))
.route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth))
@@ -142,6 +146,7 @@ struct FeedRow {
id: String,
url: String,
title: Option<String>,
image: Option<String>,
folder: Option<String>,
keywords: Vec<String>,
allow_explicit: bool,
@@ -163,6 +168,7 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
id: id.clone(),
url: feed.url.clone(),
title: s.title,
image: s.image,
folder: feed.folder.clone(),
keywords: feed.keywords.clone(),
allow_explicit: feed.allow_explicit,
@@ -223,25 +229,41 @@ struct Page {
offset: i64,
#[serde(default = "fifty")]
limit: i64,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
q: Option<String>,
}
fn fifty() -> i64 {
50
}
#[derive(Serialize)]
struct EntryPage {
total: i64,
entries: Vec<crate::db::EntryRow>,
}
async fn entries(
State(state): State<WebState>,
Path(id): Path<String>,
Query(page): Query<Page>,
) -> Result<Json<Vec<crate::db::EntryRow>>, ApiError> {
let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?;
) -> Result<Json<EntryPage>, ApiError> {
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let mut rows = state
.ctx
.db
.entries(&id, filter, search, page.offset, page.limit.clamp(1, 200))?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
}
}
Ok(Json(rows))
let total = state.ctx.db.count_entries(&id, filter, search)?;
Ok(Json(EntryPage { total, entries: rows }))
}
#[derive(Deserialize)]
@@ -437,3 +459,130 @@ async fn media(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
struct Position {
secs: i64,
}
async fn set_position(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(&feed_id, &guid, body.secs)?;
Ok(StatusCode::NO_CONTENT)
}
async fn read_all(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let n = state.ctx.db.mark_all_read(&id)?;
Ok(Json(serde_json::json!({ "marked": n })))
}
#[derive(Deserialize)]
struct HowMany {
#[serde(default = "five")]
count: i64,
}
fn five() -> i64 {
5
}
/// Queues the newest N undownloaded episodes, each as its own explicit Download command so
/// none of them is subject to the per-scan cap.
async fn download_latest(
State(state): State<WebState>,
Path(id): Path<String>,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
for enc in &ids {
state.ctx.db.requeue(*enc)?;
state
.cmds
.send(Command::Download { enclosure: *enc })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
}
Ok(Json(serde_json::json!({ "queued": ids.len() })))
}
/// Subscriptions as OPML, so they can move to another podcast app.
async fn export_opml(State(state): State<WebState>) -> Result<Response, ApiError> {
let cfg = state.ctx.cfg();
let mut doc = opml::OPML {
head: Some(opml::Head {
title: Some("ipx subscriptions".into()),
..Default::default()
}),
..Default::default()
};
for (id, feed) in &cfg.feeds {
let title = state
.ctx
.db
.feed_summary(id)
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| id.clone());
doc.add_feed(&title, &feed.url);
}
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
Ok((
[
(header::CONTENT_TYPE, "text/x-opml; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"ipx-subscriptions.opml\"",
),
],
xml,
)
.into_response())
}
#[derive(Deserialize)]
struct OpmlBody {
xml: String,
}
async fn import_opml(
State(state): State<WebState>,
Json(body): Json<OpmlBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| anyhow::anyhow!("that does not parse as OPML: {e}"))?;
let mut found = vec![];
crate::collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = (*state.ctx.cfg()).clone();
let mut added = 0;
for (title, url) in found {
if cfg.feeds.values().any(|f| f.url == url) {
continue;
}
let id = crate::config::unique_slug(&title, &cfg.feeds);
cfg.feeds.insert(
id,
crate::config::Feed {
url,
folder: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
},
);
added += 1;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(Json(serde_json::json!({ "added": added })))
}