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

@@ -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.