Leave a removed account's feeds as unsubscribing does (#157)

Removing an account deleted the user, and the foreign keys took their subscriptions with them, but
nothing did what unsubscribing does next: a feed nobody subscribes to any more leaves the catalogue
(or stays, if listed, for the Directory). A feed only that person read stayed with no subscribers,
and a feed with none downloads by its own settings, so it went on fetching each new episode, daily,
for no one, until it had been quiet for a year.

remove_feed's body is now leave(), and remove_user() leaves each of the person's feeds with it before
deleting the account, from the page and from `ipx user rm` alike; a feed inside a group of theirs
goes with the group. `ipx user rm` now tells a running daemon to reload, as add and rm do (#120),
since it can change the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:47:26 +00:00
parent 60cbb7aff6
commit 33e031dd13
3 changed files with 74 additions and 31 deletions

View File

@@ -316,7 +316,10 @@ async fn main() -> Result<()> {
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(cli.command, Command::Add { .. } | Command::Rm { .. } | Command::Import { .. });
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx {
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
@@ -507,7 +510,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
.db
.user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
ctx.db.delete_user(user.id).await?;
remove_user(ctx, user.id).await?;
println!("removed {name}");
Ok(())
}
@@ -1332,6 +1335,50 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
Ok(out)
}
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
/// lists are left alone.
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
ctx.db.unsubscribe(user_id, id).await?;
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
ctx.db.unsubscribe(user_id, &child.id).await?;
}
if ctx.db.subscriber_counts().await?.contains_key(id) {
return Ok(());
}
// 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. A feed an
// admin listed stays in the Directory, for the next person.
let mut cfg = ctx.edit_cfg().await;
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
return Ok(());
}
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.
return ctx.db.drop_managed(id).await;
}
ctx.store_cfg(cfg).await?;
retire_group(ctx, id).await
}
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
let catalogue = subscriptions(ctx).await?;
let mine: std::collections::HashSet<String> =
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
for id in &mine {
// A feed in a group of theirs goes with the group.
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
if !in_theirs {
leave(ctx, user_id, id).await?;
}
}
ctx.db.delete_user(user_id).await
}
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
@@ -2374,6 +2421,28 @@ mod tests {
}
}
#[tokio::test]
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
let ctx = test_ctx(config::Config::default()).await;
let mut cfg = ctx.edit_cfg().await;
for id in ["only-theirs", "shared", "listed"] {
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
}
ctx.store_cfg(cfg).await.unwrap();
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
for id in ["only-theirs", "shared", "listed"] {
ctx.db.subscribe(gone, id).await.unwrap();
}
ctx.db.subscribe(stays, "shared").await.unwrap();
remove_user(&ctx, gone).await.unwrap();
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
}
#[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);