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 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:51:36 +00:00
parent dc340dd4e9
commit e710103ba6
2 changed files with 44 additions and 13 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### 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. - 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. - 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. - 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.

View File

@@ -954,20 +954,11 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
let counts = db.subscriber_counts().await?; let counts = db.subscriber_counts().await?;
let media = db.media_feeds().await?; let media = db.media_feeds().await?;
let catalogue = crate::subscriptions(&state.ctx).await?; let catalogue = crate::subscriptions(&state.ctx).await?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> = let ok = listable(&catalogue);
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 mut out = vec![]; let mut out = vec![];
for s in &catalogue { for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0); let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed inside an OPML that looks private is as private as the OPML. if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
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))
{
continue; continue;
} }
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default(); 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<Vec
Ok(out) Ok(out)
} }
/// The feeds the Directory may list: not an OPML or a Patreon creator itself, which is listed as
/// the feeds in it, and neither the feed nor the group it is in private. Asked of one feed, without
/// building the whole Directory, by a feed's Directory page and by subscribing from it: building
/// it was nearly all of the 0.57 s a feed's page took (#160).
fn listable(catalogue: &[crate::Sub]) -> 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( async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
@@ -1047,7 +1058,7 @@ async fn get_listed(
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> { ) -> Result<Json<Listed>, 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"))); return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
} }
let order = crate::db::order_sql("published", "desc"); let order = crate::db::order_sql("published", "desc");
@@ -1082,7 +1093,7 @@ async fn subscribe_popular(
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, 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"))); return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
} }
state.ctx.db.subscribe(user.id, &id).await?; state.ctx.db.subscribe(user.id, &id).await?;
@@ -1248,6 +1259,25 @@ mod tests {
assert!(looks_private(&basic), "a feed with a login configured"); 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] #[test]
fn only_the_last_admin_is_protected() { fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User { let u = |id, is_admin| crate::db::User {