List feeds in the Directory before anyone subscribes, and clear out dead ones (#107)
The Directory showed a catalogue feed only once someone subscribed, and a feed left the catalogue with its last subscriber, so nothing could be put there for others to find. - A feed has a listed flag, set by ipx add --list (with --category for the Directory's chip). The web page keeps a listed feed in the catalogue when its last subscriber leaves. - The Directory lists every catalogue feed; Popular still only what people subscribe to. popular() reads titles, artwork and categories through Db::feed_list, not three queries a feed. Subscribing from the Directory scans the feed at once. - A feed nobody subscribes to is checked once a day at most. - clean_directory, in the sweep before each scan, removes from the catalogue and the database a feed nobody subscribes to, with no file on disk and not from an OPML, that has failed for 30 days or published nothing in a year. Run against production first: it removes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
28
src/web.rs
28
src/web.rs
@@ -870,8 +870,13 @@ struct PopularRow {
|
||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||
/// feeds inside it and never itself: both lists are for finding a show.
|
||||
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
/// The catalogue as others may see it. `everything` is the Directory: every feed, with or
|
||||
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it,
|
||||
/// Popular: only what people subscribe to.
|
||||
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> {
|
||||
let db = &state.ctx.db;
|
||||
// One pass for every feed's title, artwork and category, not three queries a feed.
|
||||
let mut listed = db.feed_list(user_id, None).await?;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let counts = db.subscriber_counts().await?;
|
||||
@@ -886,14 +891,14 @@ async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
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
|
||||
if (n == 0 && !everything)
|
||||
|| is_folder.contains(s.id.as_str())
|
||||
|| looks_private(&s.cfg)
|
||||
|| folder.is_some_and(|f| looks_private(f))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let sum = db.feed_summary(&s.id).await?;
|
||||
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
||||
let subscribed = mine.contains(&s.id);
|
||||
out.push(PopularRow {
|
||||
id: s.id.clone(),
|
||||
@@ -914,7 +919,7 @@ async fn get_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id).await?;
|
||||
let mut rows = popular(&state, user.id, false).await?;
|
||||
rows.truncate(10);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -924,7 +929,7 @@ async fn get_directory(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id).await?;
|
||||
let mut rows = popular(&state, user.id, true).await?;
|
||||
rows.sort_by_key(sort_name);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -940,10 +945,12 @@ async fn subscribe_popular(
|
||||
user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
|
||||
if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) {
|
||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||
}
|
||||
state.ctx.db.subscribe(user.id, &id).await?;
|
||||
// A listed feed nobody took was checked once a day at most: read it now, not in an hour.
|
||||
scan_soon(&state, Some(id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
@@ -1073,6 +1080,7 @@ mod tests {
|
||||
password: None,
|
||||
password_env: None,
|
||||
category: None,
|
||||
listed: false,
|
||||
};
|
||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||
@@ -1119,7 +1127,7 @@ mod tests {
|
||||
crate::config::Feed {
|
||||
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||
auto_download: true, max_new_per_check: None, username: None,
|
||||
password: None, password_env: None, category: None,
|
||||
password: None, password_env: None, category: None, listed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1505,8 +1513,12 @@ async fn remove_feed(
|
||||
}
|
||||
|
||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||
// someone subscribes again they do not pull the back catalogue a second time.
|
||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||
// admin listed stays in the Directory, for the next person.
|
||||
let mut cfg = (*state.ctx.cfg()).clone();
|
||||
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
if cfg.feeds.remove(&id).is_none() {
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
|
||||
Reference in New Issue
Block a user