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.
This commit is contained in:
@@ -219,6 +219,32 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
app.updatePreferences({ fetchMoreMode: next });
|
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: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Feed Refresh Interval (minutes)"
|
||||||
|
value={() => prefs().refreshIntervalMinutes}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
refreshIntervalMinutes: Math.min(120, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Whitelist management only appears while scope is set to "whitelist".
|
// Whitelist management only appears while scope is set to "whitelist".
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "manual",
|
||||||
|
refreshIntervalMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ const MAX_EPISODES_REFRESH = 50;
|
|||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
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[]) */
|
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
||||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||||
|
|
||||||
@@ -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 (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<Episode[]> => {
|
): Promise<Episode[] | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
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 xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl);
|
const parsed = parseRSSFeed(xml, feedUrl);
|
||||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||||
@@ -227,7 +241,7 @@ function createFeedStore() {
|
|||||||
|
|
||||||
return allEpisodes.slice(0, limit);
|
return allEpisodes.slice(0, limit);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,7 +281,7 @@ function createFeedStore() {
|
|||||||
const newFeed: Feed = {
|
const newFeed: Feed = {
|
||||||
id: feedId,
|
id: feedId,
|
||||||
podcast,
|
podcast,
|
||||||
episodes,
|
episodes: episodes ?? [],
|
||||||
visibility,
|
visibility,
|
||||||
sourceId,
|
sourceId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
@@ -346,6 +360,8 @@ function createFeedStore() {
|
|||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
feedId,
|
feedId,
|
||||||
);
|
);
|
||||||
|
// Fetch failed (null): keep the currently loaded episodes untouched.
|
||||||
|
if (!episodes) return;
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||||
if (updated !== prev) saveFeeds(updated);
|
if (updated !== prev) saveFeeds(updated);
|
||||||
@@ -379,6 +395,8 @@ function createFeedStore() {
|
|||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
let updated = prev;
|
let updated = prev;
|
||||||
for (const [feedId, episodes] of results) {
|
for (const [feedId, episodes] of results) {
|
||||||
|
// A failed fetch (null) leaves that feed untouched.
|
||||||
|
if (!episodes) continue;
|
||||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
||||||
}
|
}
|
||||||
if (updated !== prev) saveFeeds(updated);
|
if (updated !== prev) saveFeeds(updated);
|
||||||
@@ -422,6 +440,30 @@ function createFeedStore() {
|
|||||||
await refreshAllFeeds();
|
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<typeof setTimeout> | 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 */
|
/** Remove a feed */
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
fullEpisodeCache.delete(feedId);
|
fullEpisodeCache.delete(feedId);
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export type UserPreferences = {
|
|||||||
autoJumpToPlayer: boolean;
|
autoJumpToPlayer: boolean;
|
||||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
||||||
fetchMoreMode: FetchMoreMode;
|
fetchMoreMode: FetchMoreMode;
|
||||||
|
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||||
|
refreshIntervalMinutes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "manual",
|
||||||
refreshIntervalMinutes: 15,
|
refreshIntervalMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface ServedEpisode {
|
|||||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
let servedEpisodes: ServedEpisode[] = [];
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
let feedAId = "";
|
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). */
|
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
@@ -72,6 +74,9 @@ beforeAll(() => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
if (failPath && url.pathname === failPath) {
|
||||||
|
return new Response("feed unavailable", { status: 503 });
|
||||||
|
}
|
||||||
if (url.pathname.endsWith(".xml")) {
|
if (url.pathname.endsWith(".xml")) {
|
||||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
headers: { "Content-Type": "application/rss+xml" },
|
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);
|
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 () => {
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// Feed B: distinct URL, identical served content, so refreshing it is a
|
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||||
|
|||||||
Reference in New Issue
Block a user