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:
@@ -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.
|
||||||
|
|||||||
56
src/web.rs
56
src/web.rs
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user