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:
49
src/feed.rs
49
src/feed.rs
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user