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:
61
src/db.rs
61
src/db.rs
@@ -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();
|
||||
|
||||
10
src/feed.rs
10
src/feed.rs
@@ -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];
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
16
src/web.rs
16
src/web.rs
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user