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

@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS feeds (
url TEXT NOT NULL,
title TEXT,
image TEXT,
-- The channel's first <itunes:category>, for the Directory.
category TEXT,
etag TEXT,
last_modified TEXT,
last_checked INTEGER,
@@ -170,6 +172,7 @@ fn migrate(conn: &Connection) -> Result<()> {
("users", "created", "INTEGER"),
("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -192,6 +195,13 @@ fn migrate(conn: &Connection) -> Result<()> {
if !has(table, column)? {
tracing::info!(table, column, "adding column");
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
// A category is only read from a 200, and a feed with a validator mostly gets a
// 304, so most would never pick one up until the publisher changed something.
// Dropping the validators once makes each re-read on its normal schedule; leaving
// last_checked alone, unlike clear_validators, keeps them from all coming due at once.
if (*table, *column) == ("feeds", "category") {
conn.execute_batch("UPDATE feeds SET etag = NULL, last_modified = NULL")?;
}
}
}
for (table, column) in retired {
@@ -208,6 +218,7 @@ fn migrate(conn: &Connection) -> Result<()> {
pub struct FeedSummary {
pub title: Option<String>,
pub image: Option<String>,
pub category: Option<String>,
/// Came from a subscribed OPML that no longer lists it, but it has downloads, so it
/// was kept rather than removed.
pub orphaned: bool,
@@ -256,7 +267,8 @@ impl Db {
let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn
.query_row(
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since,
category
FROM feeds WHERE id = ?1",
[feed_id],
|r| {
@@ -267,6 +279,7 @@ impl Db {
last_error: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0,
error_since: r.get(5)?,
category: r.get(6)?,
..Default::default()
})
},
@@ -328,11 +341,14 @@ impl Db {
last_modified: Option<&str>,
ttl_mins: Option<u64>,
image: Option<&str>,
category: Option<&str>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
// category is taken as it comes, unlike title and image: a show that leaves a category
// should leave the Directory's chip too.
conn.execute(
"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)
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image, category)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET
url = excluded.url,
title = coalesce(excluded.title, feeds.title),
@@ -341,9 +357,10 @@ impl Db {
last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image),
category = excluded.category,
last_error = NULL,
error_since = NULL",
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image, category],
)?;
Ok(())
}
@@ -972,6 +989,17 @@ impl Db {
Ok(out)
}
/// Feeds with any audio or video enclosure: the Directory's Podcasts, with the rest Blogs.
/// Reaped files keep their rows, so a show whose files have all been purged still counts.
pub fn media_feeds(&self) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'",
)?;
let out = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(out)
}
/// Who else would miss this file: subscribers other than `user_id` who have starred
/// the item or have not read it yet. Deleting is deleting their copy too.
pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> {
@@ -1441,6 +1469,31 @@ pub fn now() -> i64 {
mod tests {
use super::*;
#[test]
fn adding_category_drops_validators_once_and_keeps_the_schedule() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA).unwrap();
// A database from before the column, holding a feed that would answer 304.
conn.execute_batch(
"ALTER TABLE feeds DROP COLUMN category;
INSERT INTO feeds (id, url, etag, last_modified, last_checked) VALUES ('f','u','e','lm',5);",
)
.unwrap();
let row = |conn: &Connection| -> (Option<String>, Option<String>, Option<i64>) {
conn.query_row("SELECT etag, last_modified, last_checked FROM feeds", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap()
};
migrate(&conn).unwrap();
assert_eq!(row(&conn), (None, None, Some(5)), "re-read on its normal schedule");
// Only the once: every later open keeps the validators the next poll stored.
conn.execute_batch("UPDATE feeds SET etag = 'e2'").unwrap();
migrate(&conn).unwrap();
assert_eq!(row(&conn).0.as_deref(), Some("e2"));
}
#[test]
fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().unwrap();

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];

View File

@@ -1084,6 +1084,7 @@ async fn scan_one(
last_modified.as_deref(),
parsed.ttl_mins,
parsed.image.as_deref(),
parsed.category.as_deref(),
)?;
let policy = policy_for(ctx, id, feed_cfg)?;

View File

@@ -627,6 +627,11 @@ struct PopularRow {
subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
subscribed: bool,
/// The feed's own iTunes category, if it names one; most blogs do not.
category: Option<String>,
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
/// Directory's Podcasts and Blogs between them hold everything.
podcast: bool,
}
/// Every feed that may be listed, with everyone counted, you included, most subscribers
@@ -638,6 +643,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let media = db.media_feeds()?;
let catalogue = crate::subscriptions(&state.ctx)?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
@@ -657,7 +663,15 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
}
let sum = db.feed_summary(&s.id)?;
let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
out.push(PopularRow {
id: s.id.clone(),
title: sum.title,
image: sum.image,
subscribers: n,
subscribed,
category: sum.category,
podcast: media.contains(&s.id),
});
}
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
Ok(out)