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();