From e710103ba6764c045c80237f531b7851bbf4e5c5 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 9 Oct 2026 01:51:36 +0000 Subject: [PATCH] Check one feed is in the Directory without building the Directory (#160) A feed's Directory page and subscribing from it checked the feed may be listed by building the whole Directory, every feed's listing, every file row for Podcasts and Blogs, every subscription, and looking for the id in it: 0.57 s for a feed's page in production, nearly all of it that. The rule (not an OPML or creator itself, neither the feed nor its group private) is now listable(), which the Directory filters by and the two ask of the catalogue directly. One rule, so the Directory and the check cannot drift apart; a test covers it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/web.rs | 56 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59751c8..48e845b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it. - The feed list, and each live update of it, no longer asks the database for a group's title once for every feed in the group. - An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good. - Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one. diff --git a/src/web.rs b/src/web.rs index cfe8236..786fff2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -954,20 +954,11 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result = - catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); - let is_folder: std::collections::HashSet<&str> = - catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect(); + let ok = listable(&catalogue); let mut out = vec![]; for s in &catalogue { let n = counts.get(&s.id).copied().unwrap_or(0); - // A feed inside an OPML that looks private is as private as the OPML. - let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g)); - if (n == 0 && !everything) - || is_folder.contains(s.id.as_str()) - || looks_private(&s.cfg) - || folder.is_some_and(|f| looks_private(f)) - { + if (n == 0 && !everything) || !ok.contains(s.id.as_str()) { continue; } let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default(); @@ -989,6 +980,26 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result std::collections::HashSet<&str> { + let by_id: std::collections::HashMap<&str, &crate::config::Feed> = + catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); + let is_folder: std::collections::HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect(); + catalogue + .iter() + // A feed inside an OPML that looks private is as private as the OPML. + .filter(|s| { + !is_folder.contains(s.id.as_str()) + && !looks_private(&s.cfg) + && !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f)) + }) + .map(|s| s.id.as_str()) + .collect() +} + async fn get_popular( State(state): State, user: crate::db::User, @@ -1047,7 +1058,7 @@ async fn get_listed( user: crate::db::User, Path(id): Path, ) -> Result, ApiError> { - if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) { + if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) { return Err(ApiError::not_found(format!("{id:?} is not in the directory"))); } let order = crate::db::order_sql("published", "desc"); @@ -1082,7 +1093,7 @@ async fn subscribe_popular( user: crate::db::User, Path(id): Path, ) -> Result, ApiError> { - if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) { + if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) { return Err(ApiError::bad_request(format!("{id:?} is not in the directory"))); } state.ctx.db.subscribe(user.id, &id).await?; @@ -1248,6 +1259,25 @@ mod tests { assert!(looks_private(&basic), "a feed with a login configured"); } + #[test] + fn the_directory_lists_feeds_not_their_groups_and_nothing_private() { + let sub = |id: &str, url: &str, group: Option<&str>| crate::Sub { + id: id.into(), + cfg: crate::config::Feed { group: group.map(Into::into), ..feed(url) }, + managed: group.is_some(), + }; + let catalogue = [ + sub("plain", "https://example.com/rss", None), + sub("opml", "https://example.com/list.opml", None), + sub("kid", "https://kid.example/rss", Some("opml")), + sub("paid", "https://www.patreon.com/rss/x?auth=abc", None), + sub("paid-show", "https://www.patreon.com/rss/x?auth=abc&show=1", Some("paid")), + ]; + let mut got: Vec<&str> = listable(&catalogue).into_iter().collect(); + got.sort(); + assert_eq!(got, ["kid", "plain"], "the OPML as its feeds; a private feed and its shows never"); + } + #[test] fn only_the_last_admin_is_protected() { let u = |id, is_admin| crate::db::User {