Send a changed feed's row to the page instead of it reloading the list (#105)

After a feed was checked, failed or downloaded a file, and after every item read, the page
fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event
stream now knows who is connected and, after an event that changes a feed, sends that person
its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for
one feed). Marking an item read answers with the feed's row. The page puts the row in place
and redraws once a frame. A routine skip of a feed not due sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:41:43 +00:00
parent 7a134b810f
commit c7f13eea2f
7 changed files with 107 additions and 25 deletions

View File

@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list.
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn. - A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
- An open page reloads the feed list only when a scan has checked something, not every minute. - An open page reloads the feed list only when a scan has checked something, not every minute.
- Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check. - Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check.

View File

@@ -338,8 +338,14 @@ impl Db {
/// What the feed list shows of every feed, for one person, in five queries whatever the /// What the feed list shows of every feed, for one person, in five queries whatever the
/// number of feeds. Asked feed by feed (feed_summary, http_state, blocklist, unread_count) /// number of feeds. Asked feed by feed (feed_summary, http_state, blocklist, unread_count)
/// it was six round trips a feed, about 950 for 160 feeds and 320 ms a page load (#94). /// it was six round trips a feed, about 950 for 160 feeds and 320 ms a page load (#94).
pub async fn feed_list(&self, user_id: i64) -> Result<std::collections::HashMap<String, FeedListing>> { pub async fn feed_list(
&self,
user_id: i64,
// One feed only, for the row a live update sends (`web::feed_rows`).
only: Option<&str>,
) -> Result<std::collections::HashMap<String, FeedListing>> {
use std::collections::HashMap; use std::collections::HashMap;
let one = || sea_orm::Value::from(only.map(str::to_owned));
let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move { let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move {
self.rows(sql, args) self.rows(sql, args)
.await? .await?
@@ -347,9 +353,17 @@ impl Db {
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?))) .map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
.collect::<Result<HashMap<_, _>>>() .collect::<Result<HashMap<_, _>>>()
}; };
let entries = counts("SELECT feed_id, count(*) AS n FROM entries GROUP BY feed_id", vec![]).await?; let entries = counts(
let downloaded = "SELECT feed_id, count(*) AS n FROM entries
counts("SELECT feed_id, count(*) AS n FROM enclosures WHERE path IS NOT NULL GROUP BY feed_id", vec![]) WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()],
)
.await?;
let downloaded = counts(
"SELECT feed_id, count(*) AS n FROM enclosures
WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()],
)
.await?; .await?;
let unread = counts( let unread = counts(
"SELECT e.feed_id, count(*) AS n FROM entries e "SELECT e.feed_id, count(*) AS n FROM entries e
@@ -357,10 +371,11 @@ impl Db {
LEFT JOIN entry_state s LEFT JOIN entry_state s
ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE NOT coalesce(s.read, false) WHERE NOT coalesce(s.read, false)
AND (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2)
AND NOT EXISTS (SELECT 1 FROM hidden h AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid) WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid)
GROUP BY e.feed_id", GROUP BY e.feed_id",
vec![user_id.into()], vec![user_id.into(), one()],
) )
.await?; .await?;
let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find() let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find()
@@ -370,7 +385,11 @@ impl Db {
.into_iter() .into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())) .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect(); .collect();
Ok(feeds::Entity::find() let mut rows = feeds::Entity::find();
if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id));
}
Ok(rows
.all(&self.orm) .all(&self.orm)
.await? .await?
.into_iter() .into_iter()
@@ -1964,7 +1983,10 @@ mod tests {
INSERT INTO blocklists (user_id, feed_id, words) VALUES (1,'g','[\"spoiler\"]');", INSERT INTO blocklists (user_id, feed_id, words) VALUES (1,'g','[\"spoiler\"]');",
).await ).await
.unwrap(); .unwrap();
let list = db.feed_list(1).await.unwrap(); let list = db.feed_list(1, None).await.unwrap();
let g = db.feed_list(1, Some("g")).await.unwrap();
assert_eq!(g.keys().collect::<Vec<_>>(), ["g"]);
assert_eq!((g["g"].unread, g["g"].summary.entries, g["g"].blocked.clone()), (1, 1, vec!["spoiler".to_string()]));
for id in ["f", "g"] { for id in ["f", "g"] {
let one = &list[id]; let one = &list[id];
let s = db.feed_summary(id).await.unwrap(); let s = db.feed_summary(id).await.unwrap();

View File

@@ -731,6 +731,12 @@ async fn feeds(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<FeedRow>>, ApiError> { ) -> Result<Json<Vec<FeedRow>>, ApiError> {
Ok(Json(feed_rows(&state, &user, None).await?))
}
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
let cfg = state.ctx.cfg(); let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue. // Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to. // What comes back is only the part of it this person subscribes to.
@@ -744,9 +750,9 @@ async fn feeds(
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts().await?; let counts = state.ctx.db.subscriber_counts().await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?; let pinned = state.ctx.db.pinned_feeds(user.id).await?;
let mut listed = state.ctx.db.feed_list(user.id).await?; let mut listed = state.ctx.db.feed_list(user.id, only).await?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in &subs { for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let (id, feed) = (&sub.id, &sub.cfg); let (id, feed) = (&sub.id, &sub.cfg);
// In a group, what you have not set on the feed comes from your settings on the group, // In a group, what you have not set on the feed comes from your settings on the group,
// the same fallback the scanner uses (`Db::subscribers`). // the same fallback the scanner uses (`Db::subscribers`).
@@ -809,7 +815,7 @@ async fn feeds(
pinned: pinned.contains(id), pinned: pinned.contains(id),
}); });
} }
Ok(Json(out)) Ok(out)
} }
// ---- popular on this server ---- // ---- popular on this server ----
@@ -1523,7 +1529,7 @@ async fn set_flags(
Path((feed_id, guid)): Path<(String, String)>, Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User, user: crate::db::User,
Json(body): Json<Flags>, Json(body): Json<Flags>,
) -> Result<StatusCode, ApiError> { ) -> Result<Json<Option<FeedRow>>, ApiError> {
use crate::db::EntryFlag; use crate::db::EntryFlag;
if let Some(v) = body.read { if let Some(v) = body.read {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?; state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
@@ -1531,7 +1537,9 @@ async fn set_flags(
if let Some(v) = body.flagged { if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?; state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
} }
Ok(StatusCode::NO_CONTENT) // The feed's row with its new unread count, for the page to put in place of the old one
// rather than reloading the whole list after every item read.
Ok(Json(feed_rows(&state, &user, Some(&feed_id)).await?.into_iter().next()))
} }
/// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the /// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
@@ -1644,23 +1652,45 @@ async fn fetch_now(
} }
/// The same broadcast the socket clients read, as server-sent events. /// The same broadcast the socket clients read, as server-sent events.
async fn events(State(state): State<WebState>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> { async fn events(
State(state): State<WebState>,
user: crate::db::User,
) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let rx = state.events.subscribe();
// A client that falls behind skips what it missed rather than being cut off. // A client that falls behind skips what it missed rather than being cut off.
let stream = futures_util::stream::unfold(state.events.subscribe(), |mut rx| async move { let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move {
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(ev) => { Ok(ev) => {
if let Ok(data) = serde_json::to_string(&ev) { let Ok(data) = serde_json::to_string(&ev) else { continue };
let ev = Ok::<_, std::convert::Infallible>(SseEvent::default().data(data)); let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))];
return Some((ev, rx)); // A feed that changed goes out as this person's row for it, which the page
// puts in place of the old one: it reloaded the whole list after each.
if let Some(feed) = changed_feed(&ev)
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await
&& let Some(row) = rows.first()
&& let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row }))
{
out.push(Ok(SseEvent::default().data(data)));
} }
return Some((futures_util::stream::iter(out), (rx, state, user)));
} }
Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return None, Err(broadcast::error::RecvError::Closed) => return None,
} }
} }
}); });
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
}
/// The feed an event changed what the list shows of: its counts, error or last check. Not the
/// routine skip of a feed not due, dozens a minute that change nothing.
fn changed_feed(ev: &Event) -> Option<&str> {
match ev {
Event::FeedDone { feed, .. } | Event::FeedError { feed, .. } | Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed),
_ => None,
}
} }
/// Audio, served by ServeFile so Range requests work and the player can seek. /// Audio, served by ServeFile so Range requests work and the player can seek.

View File

@@ -940,6 +940,22 @@ test('adding a page adds the feed it links, and a page with no feed is refused',
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 }); await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
}); });
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
await expect(unread).toBeVisible({ timeout: 20_000 });
const id = await unread.getAttribute('data-id');
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
const before = Number(await badge.textContent());
await unread.click();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep').first()).toBeVisible();
const lists = [];
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
await page.locator('.ep').first().click(); // opening an item reads it
await expect(badge).toHaveText(String(before - 1));
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
});
test('a deleted file looks as if it was never downloaded', async ({ page }) => { test('a deleted file looks as if it was never downloaded', async ({ page }) => {
// Other people subscribe to Picture Blog by now, so both prompts come; take them. // Other people subscribe to Picture Blog by now, so both prompts come; take them.
page.on('dialog', d => d.accept()); page.on('dialog', d => d.accept());

View File

@@ -3,7 +3,6 @@ let sse;
function connect(){ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id); const mine=id=>S.feeds.some(f=>f.id===id);
@@ -22,7 +21,7 @@ function connect(){
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone. // everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); } if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds(); refreshEntries();
} }
else if(ev.ev==='download_error'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
@@ -35,14 +34,19 @@ function connect(){
else if(ev.ev==='feed_done'){ else if(ev.ev==='feed_done'){
setScanning(ev.feed,false); setScanning(ev.feed,false);
if(!mine(ev.feed)) return; if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
} }
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's // No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why. // red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); } else if(ev.ev==='feed_error') setScanning(ev.feed,false);
// The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
else if(ev.ev==='feed_row') patchFeed(ev.row);
// Only a scan that checked something: the scheduler scans every minute, due or not, and // Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103). // every open page reloaded the whole list each time (#103).
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds){ refreshFeeds(); refreshEntries(); } } // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
} }

View File

@@ -1,4 +1,13 @@
/* ---------------- feeds ---------------- */ /* ---------------- feeds ---------------- */
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0;
function patchFeed(row){
const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
}
async function loadFeeds(keepSel?: boolean){ async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds'); S.feeds = await api('/api/feeds');
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload // Only for a hint in a feed's settings, so once, on the page's first load, not on every reload

View File

@@ -153,7 +153,7 @@ function setRead(e,read){
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})}) {method:'POST',body:JSON.stringify({read})})
.then(()=>{ w.done=performance.now(); loadFeeds(true); }); .then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
} }
/// Opening an item is reading it. The row is redrawn where it stands rather than the list /// Opening an item is reading it. The row is redrawn where it stands rather than the list