From c6ca4d1a91be131ca20da12d8431307c15bbfa1f Mon Sep 17 00:00:00 2001 From: rays Date: Sun, 25 Oct 2026 00:57:17 +0000 Subject: [PATCH] A show made a feed of its own leaves its list's group (#358) Unsung's unread counted 2 in the feed list while All Subscriptions' unread list was empty. It had been a show of an OPML that is hidden from All Subscriptions, and became a feed of its own; Db::unmanage set managed = false and left feeds.group_id pointing at the OPML, so shown_in hid it with the list. 20 feeds in production were like it. unmanage now sets the group from the feed's catalogue entry: none for a show promoted when its list dropped it or went away, the list's for a show promoted by editing its settings, which config keeps in the group. A pass at start-up moves those already left behind. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 4 +++ crates/ipx/src/catalogue.rs | 57 +++++++++++++++++++++++++++++++++++-- crates/ipx/src/daemon.rs | 7 +++++ crates/ipx/src/db.rs | 17 +++++++++-- crates/ipx/src/web.rs | 2 +- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 67d0974..17eb019 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- A podcast that was once in an OPML and is now a feed of its own is no longer hidden from All Subscriptions when that OPML is, so its unread items are listed there as the feed list counts them (#358). + ## [1.5.1] - 2026-10-24 ### Changed diff --git a/crates/ipx/src/catalogue.rs b/crates/ipx/src/catalogue.rs index 0f91d06..ed19177 100644 --- a/crates/ipx/src/catalogue.rs +++ b/crates/ipx/src/catalogue.rs @@ -406,7 +406,7 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Fe if cfg.feeds.contains_key(&m.id) { // Scanned from its config entry and still read. Dropped as derived, its stored // entries would go with it: davewiner's 11 were promoted without being unmanaged. - ctx.db.unmanage(&m.id).await?; + ctx.db.unmanage(&m.id, cfg.feeds[&m.id].group.as_deref()).await?; } else if promote_kept(ctx, &m, parent).await? { tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now"); } else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 { @@ -450,8 +450,9 @@ pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&confi // kept show went to the directory (#332). A list no longer known may have been private. private: base.is_none_or(|p| p.private), }); + let group = cfg.feeds[&m.id].group.clone(); ctx.store_cfg(cfg).await?; - ctx.db.unmanage(&m.id).await?; + ctx.db.unmanage(&m.id, group.as_deref()).await?; ctx.db.set_orphaned(&m.id, false).await?; for (user, kept) in subs { if !kept { @@ -476,6 +477,22 @@ pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result { Ok(before.len() - ctx.db.managed_feeds().await?.len()) } +/// Puts each feed no longer derived in the group its catalogue entry says, and returns how many +/// moved. Made a feed of its own before #358, a show kept its list's group in the database, and +/// hiding the list from All Subscriptions hid it too. +pub(crate) async fn regroup_unmanaged(ctx: &Ctx) -> Result { + let cfg = ctx.cfg(); + let mut moved = 0; + for (id, group) in ctx.db.unmanaged_in_groups().await? { + let Some(f) = cfg.feeds.get(&id) else { continue }; + if f.group.as_deref() != Some(group.as_str()) { + ctx.db.unmanage(&id, f.group.as_deref()).await?; + moved += 1; + } + } + Ok(moved) +} + /// A feed that answered from a new address after permanent redirects moves there in the /// catalogue, so it is read from there and no longer redirected every time. One an OPML lists /// is left alone, since the OPML would only put the old address back; so is a move onto an @@ -594,6 +611,42 @@ mod tests { assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned"); } + #[tokio::test] + async fn a_show_made_a_feed_of_its_own_is_not_hidden_with_its_list() { + // Unsung: promoted out of a Hacker News OPML hidden from All Subscriptions, its unread + // counted in the feed list and missing from All Subscriptions (#358). + let mut cfg = config::Config::default(); + cfg.feeds.insert("opml".into(), feed()); + let ctx = test_ctx(cfg).await; + ctx.db + .exec_for_test( + "INSERT INTO users (id, name, is_admin) VALUES (1,'a',true); + INSERT INTO feeds (id, url) VALUES ('opml','u0'); + INSERT INTO subscriptions (user_id, feed_id, kept) VALUES (1,'opml',false), (1,'show',true), (1,'old',true); + INSERT INTO entries (feed_id, guid, first_seen) VALUES ('show','1',1), ('old','2',2);", + ) + .await + .unwrap(); + ctx.db.upsert_managed("show", "http://x/s.xml", "Show", "opml").await.unwrap(); + ctx.db.upsert_managed("old", "http://x/o.xml", "Old", "opml").await.unwrap(); + ctx.db.set_hide_from_all(1, "opml", true).await.unwrap(); + let unread = async || ctx.db.count_of(1, None, db::Filter::Unread, None).await.unwrap(); + assert_eq!(unread().await, 0, "the list's shows are hidden with it"); + + let show = ctx.db.managed_feeds().await.unwrap().into_iter().find(|m| m.id == "show").unwrap(); + assert!(promote_kept(&ctx, &show, None).await.unwrap()); + assert_eq!(unread().await, 1, "a feed of its own is in no list"); + + // Promoted before the fix: unmanaged, still in the group, and in no group in config. + ctx.db.exec_for_test("UPDATE feeds SET managed = false WHERE id = 'old'").await.unwrap(); + let mut cfg = ctx.edit_cfg().await; + cfg.feeds.insert("old".into(), feed()); + ctx.store_cfg(cfg).await.unwrap(); + assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 1); + assert_eq!(unread().await, 2); + assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 0, "nothing left to move"); + } + #[tokio::test] async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() { // davewiner: the OPML left config before retire_group existed, and 11 of its feeds diff --git a/crates/ipx/src/daemon.rs b/crates/ipx/src/daemon.rs index 32112b3..e0a592c 100644 --- a/crates/ipx/src/daemon.rs +++ b/crates/ipx/src/daemon.rs @@ -90,6 +90,13 @@ pub(crate) async fn daemon( Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"), Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"), } + match catalogue::regroup_unmanaged(&ctx).await { + Ok(0) => {} + Ok(n) => tracing::info!(feeds = n, "took feeds of their own out of their old list's group"), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "could not take feeds of their own out of their old list's group") + } + } // Room for any one request's commands: a web request waits inside itself for a free place, // and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index cbf5df9..087629d 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -2420,12 +2420,23 @@ impl Db { Ok(()) } - /// Stops treating a feed as derived, because it now has its own config entry. - pub async fn unmanage(&self, id: &str) -> Result<()> { - self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?; + /// Stops treating a feed as derived, because it now has its own config entry, in `group` as + /// that entry says. Left in its list's group, a feed of its own was hidden with the list (#358). + pub async fn unmanage(&self, id: &str, group: Option<&str>) -> Result<()> { + self.exec("UPDATE feeds SET managed = false, group_id = $2 WHERE id = $1", vec![id.into(), group.into()]) + .await?; Ok(()) } + /// Feeds not derived any more that the database still puts in a group, with that group. + pub async fn unmanaged_in_groups(&self) -> Result> { + self.rows("SELECT id, group_id FROM feeds WHERE NOT coalesce(managed, false) AND group_id IS NOT NULL", vec![]) + .await? + .iter() + .map(|r| Ok((r.try_get("", "id")?, r.try_get("", "group_id")?))) + .collect() + } + /// Empties a feed of its items, leaving its files alone. pub async fn clear_entries(&self, feed_id: &str) -> Result<()> { entries::Entity::delete_many().filter(entries::Column::FeedId.eq(feed_id)).exec(&self.orm).await?; diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index dba89a3..9cc0980 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1884,7 +1884,7 @@ async fn patch_feed( let found = subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?; cfg.feeds.insert(id.clone(), found.cfg.clone()); - state.ctx.db.unmanage(&id).await?; + state.ctx.db.unmanage(&id, found.cfg.group.as_deref()).await?; } let checked = match &body.url {