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 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 00:57:17 +00:00
parent 474be3e0a8
commit c6ca4d1a91
5 changed files with 81 additions and 6 deletions

View File

@@ -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

View File

@@ -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<usize> {
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<usize> {
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

View File

@@ -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

View File

@@ -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<Vec<(String, String)>> {
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?;

View File

@@ -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 {