Directory: chips by kind and category over a grid of cover art

Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.

Closes #4, closes #5, closes #6, closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:09:47 +00:00
parent 97934d641a
commit 954173cacf
11 changed files with 207 additions and 38 deletions

View File

@@ -11,6 +11,8 @@ pub struct ParsedFeed {
pub title: Option<String>,
pub ttl_mins: Option<u64>,
pub image: Option<String>,
/// The channel's first `<itunes:category>`, for the Directory's chips.
pub category: Option<String>,
pub entries: Vec<Entry>,
}
@@ -535,6 +537,12 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.and_then(|i| i.image())
.map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())),
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
// would fill the Directory with one-off tags. The top level only, for the same reason.
category: ch
.itunes_ext()
.and_then(|i| i.categories().first())
.and_then(|c| non_empty(Some(c.text().trim()))),
entries,
}
}
@@ -591,6 +599,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: non_empty(Some(feed.title().as_str())),
ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
category: None,
entries,
}
}
@@ -708,6 +717,7 @@ mod tests {
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
assert_eq!(feed.ttl_mins, Some(45));
assert_eq!(feed.category.as_deref(), Some("Technology"), "the first, top level only");
assert_eq!(feed.entries.len(), 3);
let ep = &feed.entries[0];