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