An OPML's page lists its shows' items together, not the shows (#283)

GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.

The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 17:41:38 +00:00
parent ac3808fe97
commit 5cf7a28c44
11 changed files with 126 additions and 69 deletions

View File

@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).

View File

@@ -1151,15 +1151,19 @@ impl Args {
fn entries_from(
a: &mut Args,
user_id: i64,
feed_id: Option<&str>,
feeds: Option<&[&str]>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
signal: Option<i64>,
) -> String {
let user = a.p(user_id);
let mut scope = match feed_id {
Some(f) => format!("e.feed_id = {}", a.p(f)),
let mut scope = match feeds {
// One feed as before, so its page keeps its plan: an OR with the shows' list, or an IN
// over a subquery, took clarkesworld's from 10 ms to 26 ms or a second (#283).
Some([f]) => format!("e.feed_id = {}", a.p(*f)),
Some([]) => "false".into(),
Some(fs) => format!("e.feed_id IN ({})", fs.iter().map(|f| a.p(*f)).collect::<Vec<_>>().join(", ")),
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
};
if let Some(id) = signal {
@@ -1309,7 +1313,37 @@ impl Db {
limit: i64,
order: &str,
) -> Result<Vec<EntryRow>> {
self.entries_where(user_id, feed_id, filter, search, None, None, offset, limit, order).await
let feeds = feed_id.as_ref().map(std::slice::from_ref);
self.entries_where(user_id, feeds, filter, search, None, None, offset, limit, order).await
}
/// As `entries_in`, for several feeds together (an OPML's shows, #283), or every subscribed
/// feed when None.
#[allow(clippy::too_many_arguments)]
pub async fn entries_of(
&self,
user_id: i64,
feeds: Option<&[&str]>,
filter: Filter,
search: Option<&str>,
offset: i64,
limit: i64,
order: &str,
) -> Result<Vec<EntryRow>> {
self.entries_where(user_id, feeds, filter, search, None, None, offset, limit, order).await
}
/// The shows of an OPML (or a Patreon creator) this person subscribes to (#283).
pub async fn group_members(&self, user_id: i64, group: &str) -> Result<Vec<String>> {
self.rows(
"SELECT gs.feed_id FROM subscriptions gs JOIN feeds g ON g.id = gs.feed_id
WHERE gs.user_id = $1 AND g.group_id = $2",
vec![user_id.into(), group.into()],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "feed_id")?))
.collect()
}
/// A Signal's page of entries, as `entries_in` gives All Subscriptions'. The caller has made
@@ -1340,14 +1374,17 @@ impl Db {
/// plays in changes (DeadAir, #171).
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
let order = order_sql("published", "desc");
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), None, 0, 1, &order).await?.pop())
Ok(self
.entries_where(user_id, Some(&[feed_id]), Filter::All, None, Some(guid), None, 0, 1, &order)
.await?
.pop())
}
#[allow(clippy::too_many_arguments)]
async fn entries_where(
&self,
user_id: i64,
feed_id: Option<&str>,
feeds: Option<&[&str]>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
@@ -1357,7 +1394,7 @@ impl Db {
order: &str,
) -> Result<Vec<EntryRow>> {
let mut a = Args::default();
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid, signal);
let from = entries_from(&mut a, user_id, feeds, filter, search, guid, signal);
// The page's keys first, then its rows: the sort then needs only `entries_by_time`, not
// every item's row, and the show notes are read for the page alone (#188).
let (limit, offset) = (a.p(limit), a.p(offset));
@@ -1421,10 +1458,20 @@ impl Db {
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
) -> Result<i64> {
self.count_of(user_id, feed_id.as_ref().map(std::slice::from_ref), filter, search).await
}
/// As `count_in`, for several feeds together, or every subscribed feed when None.
pub async fn count_of(
&self,
user_id: i64,
feeds: Option<&[&str]>,
filter: Filter,
search: Option<&str>,
) -> Result<i64> {
let mut a = Args::default();
let sql =
format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feeds, filter, search, None, None));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}
@@ -3623,6 +3670,32 @@ mod tests {
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
}
#[tokio::test]
async fn an_opml_lists_its_shows_items_together_for_whoever_subscribes() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
INSERT INTO feeds (id, url, group_id, managed) VALUES ('a','u1','o',true), ('b','u2','o',true), ('c','u3',NULL,false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'o'), (1,'a'), (1,'b'), (1,'c'), (2,'a');
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
('a','1','A one',1), ('b','2','B two',2), ('c','3','C three',3);",
)
.await
.unwrap();
let mut shows = db.group_members(1, "o").await.unwrap();
shows.sort();
assert_eq!(shows, ["a", "b"], "the OPML's shows, not a feed outside it");
assert_eq!(db.group_members(2, "o").await.unwrap(), ["a"], "only the shows this person has");
let ids = ["a", "b", "o"];
let order = order_sql("published", "desc");
let rows = db.entries_of(1, Some(&ids), Filter::All, None, 0, 50, &order).await.unwrap();
let titles: Vec<_> = rows.iter().map(|r| r.title.clone().unwrap()).collect();
assert_eq!(titles, ["B two", "A one"], "both shows' items, newest first");
assert_eq!(db.count_of(1, Some(&ids), Filter::All, None).await.unwrap(), 2);
assert_eq!(db.count_of(1, Some(&[]), Filter::All, None).await.unwrap(), 0);
}
#[tokio::test]
async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first.

View File

@@ -1459,7 +1459,12 @@ async fn entries(
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
subscribed(&state, &user, &id).await?;
entry_page(&state, user.id, Some(&id), None, &page).await
// An OPML's own row has no items: its page is those of its shows, together, as All
// Subscriptions lists every feed's (#283).
let mut ids = state.ctx.db.group_members(user.id, &id).await?;
ids.push(id);
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
entry_page(&state, user.id, Some(&ids[..]), None, &page).await
}
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
@@ -1483,11 +1488,11 @@ async fn all_entries(
entry_page(&state, user.id, None, None, &page).await
}
/// One feed's page of items, or every subscribed feed's when `feed` is None, or a Signal's.
/// Some feeds' page of items, or every subscribed feed's when `feeds` is None, or a Signal's.
async fn entry_page(
state: &WebState,
user_id: i64,
feed: Option<&str>,
feeds: Option<&[&str]>,
signal: Option<i64>,
page: &Page,
) -> Result<Json<EntryPage>, ApiError> {
@@ -1504,7 +1509,7 @@ async fn entry_page(
.await?
}
None => {
db.entries_in(user_id, feed, filter, search, page.offset, limit, &order)
db.entries_of(user_id, feeds, filter, search, page.offset, limit, &order)
.instrument(step("entries_in"))
.await?
}
@@ -1520,7 +1525,7 @@ async fn entry_page(
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
let total = match signal {
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
None => db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?,
None => db.count_of(user_id, feeds, filter, search).instrument(step("count_in")).await?,
};
Ok(Json(EntryPage { total, entries: rows }))
}