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();
|
||||
|
||||
Reference in New Issue
Block a user