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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user