From 2d7d49b91c38c054168562a2bc6c2e5935a5e224 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 13:11:20 -0400 Subject: [PATCH] feat(feed): periodic background refresh with failed-fetch guard Self-rescheduling refresh timer (default 30 min, configurable via a Preferences item, re-read on every tick, skips in-flight refreshes). fetchEpisodes returns null on network failure/timeout so a failed refresh can never wipe a feed's episodes (addFeed/refreshFeed/ refreshAllFeeds all treat null as unchanged); feeds still refresh on launch. --- src/pages/Settings/PreferencesPanel.tsx | 26 +++++++++++++ src/stores/app.ts | 1 + src/stores/feed.ts | 52 ++++++++++++++++++++++--- src/types/settings.ts | 2 + src/utils/app-persistence.ts | 2 +- tests/feed-refresh.test.ts | 30 ++++++++++++++ 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 6d9fc28..0d3d12f 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -219,6 +219,32 @@ export function usePreferencesItems(): SettingItem[] { app.updatePreferences({ fetchMoreMode: next }); }, }, + { + id: "refreshInterval", + label: "Feed Refresh Interval", + kind: "number", + display: () => `${prefs().refreshIntervalMinutes} min`, + help: () => + `How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`, + cycle: (dir) => { + const next = Math.min( + 120, + Math.max(1, prefs().refreshIntervalMinutes + dir * 5), + ); + app.updatePreferences({ refreshIntervalMinutes: next }); + }, + renderEditor: () => ( + prefs().refreshIntervalMinutes} + commit={(n) => { + app.updatePreferences({ + refreshIntervalMinutes: Math.min(120, n), + }); + }} + /> + ), + }, ]; // Whitelist management only appears while scope is set to "whitelist". diff --git a/src/stores/app.ts b/src/stores/app.ts index 32d1223..389722e 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -42,6 +42,7 @@ const defaultPreferences: UserPreferences = { autoDownloadWhitelist: [], autoJumpToPlayer: true, fetchMoreMode: "manual", + refreshIntervalMinutes: 30, }; const defaultState: AppState = { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 76ee61f..f3fb9fa 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -29,6 +29,13 @@ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; +/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or + * the background refresh loop. */ +const FETCH_TIMEOUT_MS = 20_000; + +/** Default minutes between automatic background feed refreshes. */ +const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; + /** Cache of all parsed episodes per feed (feedId -> Episode[]) */ const fullEpisodeCache = new Map(); @@ -201,20 +208,27 @@ function createFeedStore() { ); }; - /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ + /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes. + * Returns NULL when the feed could not be fetched (network error, non-OK + * response, timeout) — callers must treat null as "unchanged" and keep + * the previously loaded episodes. A failed refresh must never look like + * an empty feed, or the store would wipe a subscribed show's episodes. */ const fetchEpisodes = async ( feedUrl: string, limit: number, feedId?: string, - ): Promise => { + ): Promise => { try { const response = await fetch(feedUrl, { headers: { "Accept-Encoding": "identity", Accept: "application/rss+xml, application/xml, text/xml, */*", }, + // Hung feeds must not stall a refresh batch (or the background + // refresh loop) indefinitely. + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); - if (!response.ok) return []; + if (!response.ok) return null; const xml = await response.text(); const parsed = parseRSSFeed(xml, feedUrl); const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); @@ -227,7 +241,7 @@ function createFeedStore() { return allEpisodes.slice(0, limit); } catch { - return []; + return null; } }; @@ -267,7 +281,7 @@ function createFeedStore() { const newFeed: Feed = { id: feedId, podcast, - episodes, + episodes: episodes ?? [], visibility, sourceId, lastUpdated: new Date(), @@ -346,6 +360,8 @@ function createFeedStore() { MAX_EPISODES_REFRESH, feedId, ); + // Fetch failed (null): keep the currently loaded episodes untouched. + if (!episodes) return; setFeeds((prev) => { const updated = applyRefreshedEpisodes(prev, feedId, episodes); if (updated !== prev) saveFeeds(updated); @@ -379,6 +395,8 @@ function createFeedStore() { setFeeds((prev) => { let updated = prev; for (const [feedId, episodes] of results) { + // A failed fetch (null) leaves that feed untouched. + if (!episodes) continue; updated = applyRefreshedEpisodes(updated, feedId, episodes); } if (updated !== prev) saveFeeds(updated); @@ -422,6 +440,30 @@ function createFeedStore() { await refreshAllFeeds(); })(); + // ── Background refresh ────────────────────────────────────────────────── + // New episodes only reach the app while it runs if feeds are re-fetched + // on a schedule: startup and manual `r` alone leave a subscribed show's + // latest episode invisible until the user restarts (or presses r). A + // self-rescheduling timer re-reads the interval preference on every tick + // so a settings change takes effect without a restart, and skips a tick + // that would overlap an in-flight refresh (manual or background). + let refreshTimer: ReturnType | null = null; + const scheduleNextRefresh = () => { + if (refreshTimer) clearTimeout(refreshTimer); + const minutes = Math.max( + 1, + useAppStore().state().preferences.refreshIntervalMinutes ?? + DEFAULT_REFRESH_INTERVAL_MINUTES, + ); + refreshTimer = setTimeout(() => { + if (!isLoadingFeeds()) { + refreshAllFeeds().catch(() => {}); + } + scheduleNextRefresh(); + }, minutes * 60_000); + }; + scheduleNextRefresh(); + /** Remove a feed */ const removeFeed = (feedId: string) => { fullEpisodeCache.delete(feedId); diff --git a/src/types/settings.ts b/src/types/settings.ts index 591f370..07f4c74 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -105,6 +105,8 @@ export type UserPreferences = { autoJumpToPlayer: boolean; /** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */ fetchMoreMode: FetchMoreMode; + /** Minutes between automatic background feed refreshes (default: 30). */ + refreshIntervalMinutes: number; }; export type AppState = { diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 90b353a..7cda74d 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -46,7 +46,7 @@ const defaultPreferences: UserPreferences = { autoDownloadWhitelist: [], autoJumpToPlayer: true, fetchMoreMode: "manual", - refreshIntervalMinutes: 15, + refreshIntervalMinutes: 30, }; const defaultState: AppState = { diff --git a/tests/feed-refresh.test.ts b/tests/feed-refresh.test.ts index 6c7c9c5..32a7759 100644 --- a/tests/feed-refresh.test.ts +++ b/tests/feed-refresh.test.ts @@ -37,6 +37,8 @@ interface ServedEpisode { let server: ReturnType | null = null; let servedEpisodes: ServedEpisode[] = []; let feedAId = ""; +/** When set, the server 503s this path — simulates a feed going down. */ +let failPath: string | null = null; /** XML for the current served episode list (episode ids = feedUrl#index). */ function feedXml(episodes: ServedEpisode[], origin: string): string { @@ -72,6 +74,9 @@ beforeAll(() => { port: 0, fetch(req) { const url = new URL(req.url); + if (failPath && url.pathname === failPath) { + return new Response("feed unavailable", { status: 503 }); + } if (url.pathname.endsWith(".xml")) { return new Response(feedXml(servedEpisodes, url.origin), { headers: { "Content-Type": "application/rss+xml" }, @@ -130,6 +135,31 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => { expect(after.episodes.length).toBe(4); }); +test("a failed refresh does not wipe the feed's episodes", async () => { + const store = useFeedStore(); + servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }]; + const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const feedId = feed!.id; + expect(store.getFeed(feedId)!.episodes.length).toBe(1); + + // The feed now 503s. fetchEpisodes returns null, and both refresh paths + // must leave the loaded episodes untouched — a failed refresh must never + // look like an empty feed (which would wipe the show's episodes). + failPath = "/flaky.xml"; + vi.advanceTimersByTime(60_000); + await store.refreshFeed(feedId); + expect(store.getFeed(feedId)!.episodes.length).toBe(1); + + vi.advanceTimersByTime(60_000); + await store.refreshAllFeeds(); + expect(store.getFeed(feedId)!.episodes.length).toBe(1); + + failPath = null; + store.removeFeed(feedId); +}); + test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => { const store = useFeedStore(); // Feed B: distinct URL, identical served content, so refreshing it is a