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:
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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.
|
||||
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
|
||||
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
|
||||
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
|
||||
|
||||
73
src/main.rs
73
src/main.rs
@@ -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);
|
||||
|
||||
31
src/web.rs
31
src/web.rs
@@ -558,7 +558,7 @@ async fn remove_user(
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.delete_user(id).await?;
|
||||
crate::remove_user(&state.ctx, id).await?;
|
||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1662,34 +1662,7 @@ async fn remove_feed(
|
||||
Path(id): Path<String>,
|
||||
user: crate::db::User,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
||||
// else's alone.
|
||||
state.ctx.db.unsubscribe(user.id, &id).await?;
|
||||
for child in crate::subscriptions(&state.ctx).await?
|
||||
.iter()
|
||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
||||
}
|
||||
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// 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 = state.ctx.edit_cfg().await;
|
||||
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.
|
||||
state.ctx.db.drop_managed(&id).await?;
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
crate::retire_group(&state.ctx, &id).await?;
|
||||
crate::leave(&state.ctx, user.id, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user