/** * Feed store for PodTUI * Manages feed data, sources, and filtering */ import { createSignal } from "solid-js"; import { Effect } from "effect"; import { refreshFeedsBatch } from "../effects/feed-refresh"; import { FeedVisibility } from "../types/feed"; import type { Feed } from "../types/feed"; import type { Podcast } from "../types/podcast"; import type { Episode } from "../types/episode"; import type { PodcastSource } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source"; import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser"; import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client"; import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver"; import { savePodcastIndexCredentials } from "../utils/source-credentials"; import { mergeEpisodesBounded } from "../utils/episode-merge"; import { episodeKeepFn, episodeTs, dateFetchMoreCutoff, dateBandCount, sameRefreshWindow, } from "../utils/episode-windows"; import { createSourceRegistry } from "../utils/source-registry"; import { createPersistScheduler } from "./persist"; import { DEFAULT_EPISODE_WINDOW_DAYS, loadFeedsFromFile, saveFeedsToFile, saveSourcesToFile, loadSourcesFromFile, } from "../utils/feeds-persistence"; import { useActivityStore } from "./activity"; import { useDownloadStore } from "./download"; import { useAppStore } from "./app"; import { DownloadStatus } from "../types/episode"; /** Max episodes to load per page/chunk (count mode only — date mode steps * by episode-windows' fetch-more band instead). */ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; /** Bounds simultaneous RSS requests during a refresh batch — a hung feed * burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole * batch. */ const FETCH_CONCURRENCY = 4; /** Default minutes between automatic background feed refreshes. */ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; /** Max episodes parsed per chunk before yielding to the event loop — bounds * the synchronous regex work per frame so one huge feed (or a batch of * feeds) can't stall the renderer. */ const PARSE_CHUNK_SIZE = 5; /** Hard ceiling on the in-memory full-parse cache per feed (newest first). * The cache exists so fetch-more can page deeper without a refetch; without * a ceiling a 5,000-episode archive pins tens of MB of Episode objects in * RAM for the whole session (the old cache held EVERY parsed episode of * every feed, contributing hundreds of MB for archive-heavy * subscriptions). 1000 covers any realistic show's entire history — * beyond it, hasMoreEpisodes flips false and the visible list is bounded * by the user's cache preference as usual. */ const MAX_CACHED_EPISODES_PER_FEED = 1000; /** Yield to the event loop (task queue) so the renderer can paint between * parse chunks. MessageChannel instead of setTimeout/setImmediate because * bun:test fake timers trap those (feed-refresh/pagination tests run under * vi.useFakeTimers and await refreshes, so a trapped yield would deadlock * them); MessageChannel posts are real task-queue turns that fire in both * environments. */ const yieldToUI = (): Promise => new Promise((resolve) => { const { port1, port2 } = new MessageChannel(); port1.onmessage = () => { port1.close(); port2.close(); resolve(); }; port2.postMessage(null); }); /** Parse all episodes from feed XML in bounded chunks, yielding to the event * loop between chunks. The whole-feed sync `parseRSSFeed` would otherwise * block the UI thread for the combined parse time of every feed in a * refresh batch. */ const parseEpisodesIncremental = async ( xml: string, feedUrl: string, ): Promise => { const items = getRSSItems(xml); // Yield after the item-extraction regex (which scans the full XML // synchronously) so the renderer paints before the first parse chunk. await yieldToUI(); const episodes: Episode[] = new Array(items.length); for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) { const end = Math.min(start + PARSE_CHUNK_SIZE, items.length); for (let i = start; i < end; i++) { episodes[i] = parseRSSItem(items[i], feedUrl, i); } if (end < items.length) await yieldToUI(); } return episodes; }; /** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the * full parse — the bound (count or date) is applied when reading, not when * writing, so changing the preference takes effect without a refetch. * Fetch-more reads beyond the bound from this cache (volatile only — the * cache itself is never extended by fetch-more). */ const fullEpisodeCache = new Map(); /** Track how many episodes are currently loaded (visible) per feed. The * loaded window grows via fetch-more but never exceeds what the cache * holds — when it reaches the cache length, hasMoreEpisodes flips false. */ const episodeLoadCount = new Map(); /** Write closure for the persist scheduler — reads the live feed signal * (wired by createFeedStore) so a flush always lands the latest value. */ let readFeeds: () => Feed[] = () => []; /** Shared trailing-edge debouncer for config.json writes ("feeds" domain); * sources persist immediately instead. */ const persistScheduler = createPersistScheduler(() => { const prefs = useAppStore().state().preferences; saveFeedsToFile( readFeeds(), prefs.episodeCacheMode === "date" ? Math.max(1, prefs.episodeCacheDays) : undefined, ); }); /** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS * keychain, marking the source hasCredentials and stripping the plaintext. * When the keychain is unavailable the plaintext stays (marked as the * plaintext storage backend) so the source keeps working. * Returns the same array when nothing needed migrating. */ async function migratePlaintextCredentials( sources: PodcastSource[], ): Promise { let changed = false; const migrated: PodcastSource[] = []; for (const source of sources) { if ( source.id === "podcastindex" && source.apiKey && source.apiSecret && !source.hasCredentials ) { const ok = await savePodcastIndexCredentials( source.apiKey, source.apiSecret, ); if (ok) { migrated.push({ ...source, apiKey: undefined, apiSecret: undefined, hasCredentials: true, credentialStorage: "keychain", }); } else { migrated.push({ ...source, hasCredentials: true, credentialStorage: "plaintext", }); } changed = true; continue; } migrated.push(source); } return changed ? migrated : sources; } function createFeedStore() { const [feeds, setFeeds] = createSignal([]); readFeeds = () => feeds(); const registry = createSourceRegistry(DEFAULT_SOURCES); const [isLoadingMore, setIsLoadingMore] = createSignal(false); const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); /** Feed-page fetch-more presses in COUNT mode: the global list is capped * at episodeCacheCount × (presses + 1) episodes, so one press reveals * exactly N more of the NEWEST episodes across all shows — it can never * dump deep history (see getAllEpisodesChronological). */ const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0); const scheduleSaveFeeds = (): void => { persistScheduler.schedule("feeds"); }; const flushPendingSave = (): void => { persistScheduler.flush("feeds"); }; const getFilteredFeeds = (): Feed[] => { // The filter signal is write-only (no caller mutates it), so every // caller observes the defaults: "all" visibility and the stable // "updated desc" sort with pinned feeds first. const result = [...feeds()]; result.sort( (a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(), ); result.sort((a, b) => { if (a.isPinned && !b.isPinned) return -1; if (!a.isPinned && b.isPinned) return 1; return 0; }); return result; }; const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed; }> => { const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []; for (const feed of feeds()) { for (const episode of feed.episodes) { allEpisodes.push({ episode, feed }); } } // Sort by publication date (newest first) allEpisodes.sort( (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), ); // COUNT mode: the Feed page is a GLOBAL top-K list — the newest // `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL // shows, not N per show. A press reveals exactly N more recent // episodes; deep history never surfaces in one jump. The cap stays // even once every cache is exhausted (the button hides) — lifting it // rendered the full deep union and froze the UI. const prefs = useAppStore().state().preferences; if (prefs.episodeCacheMode === "count") { const limit = Math.max(1, prefs.episodeCacheCount ?? 25) * (countFetchMorePresses() + 1); return allEpisodes.slice(0, limit); } return allEpisodes; }; const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { return [...episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); }; /** Fetch latest episodes from an RSS feed URL, caching ALL parsed * episodes in fullEpisodeCache. The visible episodes returned are * bounded by the user's cache preference (count or date); the full * cache survives so fetch-more can page beyond the bound without a * refetch (volatile only — the cache is never extended by fetch-more). * Returns NULL episodes on any failure — a failed fetch must not look * like an empty feed, or the store would wipe a subscribed show's * episodes. Also returns the channel-level artwork so callers can * backfill a feed's coverUrl (subscribe + refresh). */ const fetchEpisodes = async ( feedUrl: string, limit: number, feedId?: string, ): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => { try { const xml = await fetchFeedXml(feedUrl); if (xml === null) return { episodes: null, coverUrl: undefined }; // Yield after the network read so the renderer gets a turn // before the sync regex + parse work begins. await yieldToUI(); const allEpisodes = sortEpisodesReverseChronological( await parseEpisodesIncremental(xml, feedUrl), ); if (feedId) { // Cache the FULL parse — the bound is applied when reading, // not when writing, so a preference change takes effect // without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED // so an archive-heavy feed can't pin its entire history in // RAM for the session (the visible window below is bounded // by the user's preference regardless). fullEpisodeCache.set( feedId, allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED), ); } // Bound the visible window by the user's cache preference. const prefs = useAppStore().state().preferences; const keep = episodeKeepFn(prefs); const bounded = allEpisodes.filter((ep, i) => keep(ep, i)); const visible = bounded.slice(0, limit); if (feedId) { // Track how many episodes are visible — the bounded window, // not the full parse. hasMoreEpisodes compares this to the // full cache length to decide if fetch-more can page deeper. episodeLoadCount.set(feedId, visible.length); } return { episodes: visible, coverUrl: parseChannelCoverUrl(xml), }; } catch { return { episodes: null, coverUrl: undefined }; } }; const hasFeedByUrl = (feedUrl: string): boolean => { return feeds().some((f) => f.podcast.feedUrl === feedUrl); }; /** Add a new feed and auto-fetch latest 20 episodes */ const addFeed = async ( podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC, ): Promise => { const activity = useActivityStore(); // The "Subscribing" label covers the directory-resolve + subscribe // fetch stretch — the gaps no existing signal (isLoadingFeeds, // per-pane spinners) covers. return activity.track((async () => { // A directory stub (e.g. a show delisted from Apple Podcasts) has no // feed URL; resolve the real feed from its directory page before // subscribing. Refuse when it can't be resolved rather than adding a // broken feed. if (!podcast.feedUrl) { if (!podcast.directoryUrl) return null; const resolved = await resolveItunesFeedUrl(podcast.directoryUrl); if (!resolved) return null; podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined }; } // Guard: don't add a feed we already have (matched by feedUrl) if (hasFeedByUrl(podcast.feedUrl)) { return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null; } const feedId = crypto.randomUUID(); const { episodes, coverUrl } = await fetchEpisodes( podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId, ); if (!podcast.coverUrl && coverUrl) { podcast = { ...podcast, coverUrl }; } const newFeed: Feed = { id: feedId, podcast, episodes: episodes ?? [], visibility, sourceId, lastUpdated: new Date(), isPinned: false, }; setFeeds((prev) => { const updated = [...prev, newFeed]; scheduleSaveFeeds(); return updated; }); // Global auto-download: newly subscribed shows join the next pass. runAutoDownload(); return newFeed; })(), "Subscribing"); }; /** Download the N most recent episodes of every in-scope show, per the * global auto-download preferences (master toggle + scope + whitelist + * count). Skips episodes already downloaded, queued, or in flight; * retries failed ones. Idempotent — safe to run after any settings * change, feed refresh, or subscribe. */ const runAutoDownload = (): void => { const app = useAppStore(); const prefs = app.state().preferences; if (!prefs.autoDownload || prefs.autoDownloadScope === "none") return; const whitelist = prefs.autoDownloadWhitelist ?? []; const count = Math.max(1, prefs.autoDownloadCount ?? 2); const dlStore = useDownloadStore(); for (const feed of feeds()) { if ( prefs.autoDownloadScope === "whitelist" && !whitelist.includes(feed.id) ) { continue; } const sorted = [...feed.episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); for (const ep of sorted.slice(0, count)) { const status = dlStore.getDownloadStatus(ep.id); if ( status === DownloadStatus.NONE || status === DownloadStatus.FAILED ) { dlStore.startDownload(ep, feed.id); } } } }; /** Apply a freshly fetched episode list to one feed, bumping `lastUpdated` * only when the content actually changed (see sameRefreshWindow). The * fetched window is MERGED into the existing episodes (fetched copy wins * on id collision) so a refresh never shrinks the in-memory list; the * union is pruned by the user's cache bound (count or date) so episodes * outside the bound fall out of the visible list on the next refresh. * Returns the ORIGINAL array reference when nothing changed so callers * skip persistence entirely — a refresh that fetched identical episodes * must not re-sort the "updated" view. */ const applyRefreshedEpisodes = ( prev: Feed[], feedId: string, episodes: Episode[], ): Feed[] => { let changed = false; const prefs = useAppStore().state().preferences; const keep = episodeKeepFn(prefs); const updated = prev.map((f) => { if (f.id !== feedId) return f; const merged = mergeEpisodesBounded(f.episodes, episodes, keep); if (sameRefreshWindow(f.episodes, episodes)) return f; changed = true; return { ...f, episodes: merged, lastUpdated: new Date() }; }); return changed ? updated : prev; }; /** Refresh a single feed - re-fetch latest 50 episodes */ const refreshFeed = async (feedId: string) => { const activity = useActivityStore(); return activity.track((async () => { const feed = getFeed(feedId); if (!feed) return; const { episodes, coverUrl } = await fetchEpisodes( feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId, ); // Fetch failed (null): keep the currently loaded episodes untouched. if (!episodes) return; setFeeds((prev) => { let updated = applyRefreshedEpisodes(prev, feedId, episodes); if (coverUrl) { updated = updated.map((f) => f.id === feedId && !f.podcast.coverUrl && coverUrl ? { ...f, podcast: { ...f.podcast, coverUrl } } : f, ); } if (updated !== prev) scheduleSaveFeeds(); return updated; }); // Global auto-download: ensure the N most recent episodes of in-scope // shows are available offline after every refresh (idempotent). runAutoDownload(); })(), "Refreshing"); }; /** Refresh all feeds via the Effect batch program (effects/feed-refresh): * bounded concurrency (at most FETCH_CONCURRENCY in-flight requests) * and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS * (no barrier — the apply runs inside the feed's own fiber). Per-feed * apply is safe because applyRefreshedEpisodes keeps unchanged feeds' * object identity and lastUpdated (union merge), so each feed's * refreshed episodes render as its own fetch resolves — the order * flapping the old atomic barrier existed to hide can no longer * happen. A failed or timed-out fetch (null episodes) leaves that * feed untouched. */ const refreshAllFeeds = async () => { setIsLoadingFeeds(true); try { await Effect.runPromise( refreshFeedsBatch( feeds(), (feed) => fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id), (feed, { episodes, coverUrl }) => { setFeeds((prev) => { let updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (coverUrl) { updated = updated.map((f) => f.id === feed.id && !f.podcast.coverUrl && coverUrl ? { ...f, podcast: { ...f.podcast, coverUrl } } : f, ); } if (updated !== prev) scheduleSaveFeeds(); return updated; }); }, { concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS }, ), ); // Global auto-download: one idempotent pass after the batch. runAutoDownload(); // A refresh batch always ends with a persisted write when // anything changed — never leave the debounce's trailing edge // pending across a process exit. flushPendingSave(); } finally { setIsLoadingFeeds(false); } }; // Resolves once the persisted feeds are loaded and visible to feeds() — // before the background refresh so boot-time consumers (player-session // restore) don't wait on the network. const { promise: feedsReady, resolve: resolveFeedsReady } = Promise.withResolvers(); (async () => { const loadedFeeds = await loadFeedsFromFile( useAppStore().state().preferences.episodeCacheMode === "date" ? Math.max(1, useAppStore().state().preferences.episodeCacheDays) : undefined, ); if (loadedFeeds.length > 0) setFeeds(loadedFeeds); resolveFeedsReady(); const loadedSources = await loadSourcesFromFile(); // The default "rss" placeholder source fabricated fake search results // and was removed from DEFAULT_SOURCES; drop it from persisted configs // too. User-added custom feeds keep their own ids and are untouched. const migratedSources = loadedSources?.filter((source) => source.id !== "rss") ?? []; // Default sources fill gaps in persisted configs (so new defaults like // the Podcast Index fallback reach existing installs), while a // persisted source with the same id always wins over its default — // user edits (keys, enabled, country) are never clobbered. const mergedSources = [ ...migratedSources, ...DEFAULT_SOURCES.filter( (defaultSource) => !migratedSources.some((s) => s.id === defaultSource.id), ), ]; if (mergedSources.length > 0) { // One-time credential migration: sources persisted with plaintext // apiKey/apiSecret (pre-keychain builds) move into the macOS // keychain and are stripped from config.json. const secured = await migratePlaintextCredentials(mergedSources); registry.replaceAll(secured); if (secured !== mergedSources) saveSourcesToFile(secured); } 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(); const removeFeed = (feedId: string) => { fullEpisodeCache.delete(feedId); episodeLoadCount.delete(feedId); setFeeds((prev) => { const updated = prev.filter((f) => f.id !== feedId); // Unsubscribe intent must not sit in the debounce window if the // process exits — persist the removal immediately. scheduleSaveFeeds(); flushPendingSave(); return updated; }); }; /** Remove a feed by its RSS URL (for sources that match by URL, not ID) */ const removeFeedByUrl = (feedUrl: string) => { const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl); if (feed) { fullEpisodeCache.delete(feed.id); episodeLoadCount.delete(feed.id); setFeeds((prev) => { const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl); // Unsubscribe intent must not sit in the debounce window if // the process exits — persist the removal immediately. scheduleSaveFeeds(); flushPendingSave(); return updated; }); } }; const getFeed = (feedId: string): Feed | undefined => { return feeds().find((f) => f.id === feedId); }; /** Find an episode by ID across all loaded feeds (undefined when the * episode isn't in any loaded window, e.g. an unsubscribed show). */ const findEpisode = (episodeId: string): Episode | undefined => { for (const feed of feeds()) { const ep = feed.episodes.find((e) => e.id === episodeId); if (ep) return ep; } return undefined; }; /** Check if a feed has more episodes available beyond what's currently * loaded. The full parse cache holds ALL episodes (including beyond the * cache bound), so fetch-more can page deeper — but in DATE mode only * when the next unloaded episode falls inside the next 2-week band: a * sparse/dormant show whose band is empty reports false, so fetch-more * never drags in arbitrarily old episodes just because the parse cache * holds them. When the loaded window reaches the cache length (or the * band is empty), this flips false. */ const hasMoreEpisodes = (feedId: string): boolean => { const cached = fullEpisodeCache.get(feedId); if (!cached) return false; const loaded = episodeLoadCount.get(feedId) ?? 0; if (loaded >= cached.length) return false; const prefs = useAppStore().state().preferences; if (prefs.episodeCacheMode === "count") return true; const cutoff = dateFetchMoreCutoff( cached, loaded, prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS, ); return episodeTs(cached[loaded]) >= cutoff; }; /** Load the next chunk of episodes for one feed from the full parse * cache — VOLATILE only: the episodes surfaced beyond the cache bound * are held in the feed's in-memory episode list (so the user can browse * them) but are NOT written back to fullEpisodeCache (the cache keeps * its original bounded shape; these episodes vanish on the next * refresh or restart). The cache is populated by fetchEpisodes/refresh; * a cold cache (post-restart) triggers a refetch here. * No global guard — callers own the `isLoadingMore` flag so batches * (loadMoreAllFeeds) can loop over multiple feeds in one go. */ const loadMoreEpisodesForFeed = async (feedId: string) => { const feed = getFeed(feedId); if (!feed) return; let cached = fullEpisodeCache.get(feedId); // If no cache, re-fetch and parse the full feed (cold path after a // restart). The cache holds the FULL parse — no bound applied here. if (!cached) { try { const xml = await fetchFeedXml(feed.podcast.feedUrl); if (xml === null) return; cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl); } catch { // Failed/hung refetch: leave the feed's loaded episodes // untouched rather than throwing out of loadMoreEpisodes. return; } // Cold-refetch parse output is unsorted; sort it newest-first. // Yield before the sync sort (the parse already yielded before // this point, but the sort of potentially hundreds of episodes // is its own sync block). await yieldToUI(); cached = sortEpisodesReverseChronological(cached); // Same ceiling as fetchEpisodes: the cache (and the paging // window below) never exceeds MAX_CACHED_EPISODES_PER_FEED. cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED); fullEpisodeCache.set(feedId, cached); // Set current load count to match what's already displayed episodeLoadCount.set(feedId, feed.episodes.length); } const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; const prefs = useAppStore().state().preferences; // Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band // past the oldest loaded episode (or the cache-window edge when // nothing is loaded) — a daily show gains ~2 weeks of episodes, a // weekly show gains its next 2, never a fixed count. An empty band // is a genuine stop (hasMoreEpisodes hides the button) — no minimum, // so a sparse/dormant show can't grab arbitrarily old episodes. // Count mode keeps the fixed MAX_EPISODES_REFRESH chunk. let newCount: number; if (prefs.episodeCacheMode === "date") { const cutoff = dateFetchMoreCutoff( cached, currentCount, prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS, ); newCount = dateBandCount(cached, currentCount, cutoff); } else { newCount = currentCount + MAX_EPISODES_REFRESH; } newCount = Math.min(newCount, cached.length); if (newCount <= currentCount) return; // nothing more to load // Advance the loaded window — volatile: the episodes beyond the cache // bound are held in feed.episodes (visible) but the cache itself is // NOT extended. episodeLoadCount tracks the volatile window size. episodeLoadCount.set(feedId, newCount); const episodes = cached.slice(0, newCount); // Yield a real macrotask turn before the sync state update so the // renderer paints the spinner and processes keyboard input before the // (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort // runs. Without this, the whole body executes in one microtask batch // and the UI freezes through every feed in the batch. await yieldToUI(); setFeeds((prev) => { const updated = prev.map((f) => f.id === feedId ? { ...f, episodes } : f, ); scheduleSaveFeeds(); return updated; }); }; /** Load the next chunk of episodes for a feed from the cache. * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ const loadMoreEpisodes = async (feedId: string) => { if (isLoadingMore()) return; setIsLoadingMore(true); try { await loadMoreEpisodesForFeed(feedId); } finally { setIsLoadingMore(false); } }; const hasMoreAcrossAll = (): boolean => { return feeds().some((f) => hasMoreEpisodes(f.id)); }; /** Power the Feed page's "[Fetch More]". * Date mode: advance each feed's window by its 2-week band (empty bands * — sparse/dormant shows — are skipped). * Count mode: the global list cap grows by one count (see * getAllEpisodesChronological) and every feed's window deepens by one * count so the growing cap has material; one press reveals exactly N * more RECENT episodes, never a far-back dump. * Both modes compute every feed's new window FIRST (yielding between * feeds so the renderer keeps painting) and apply ONE setFeeds — the * Feed list rebuilds once per press instead of once per feed (the * per-feed storms froze the UI). */ const loadMoreAllFeeds = async () => { if (isLoadingMore()) return; setIsLoadingMore(true); try { const prefs = useAppStore().state().preferences; const count = Math.max(1, prefs.episodeCacheCount ?? 25); if (prefs.episodeCacheMode === "count") { setCountFetchMorePresses((p) => p + 1); } const windowDays = prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS; const updates: Array<{ feedId: string; episodes: Episode[] }> = []; for (const feed of feeds()) { const cached = fullEpisodeCache.get(feed.id); if (!cached) continue; const currentCount = episodeLoadCount.get(feed.id) ?? feed.episodes.length; if (currentCount >= cached.length) continue; let newCount: number; if (prefs.episodeCacheMode === "count") { newCount = Math.min(currentCount + count, cached.length); } else { // Date mode: skip feeds whose next band is empty — the // button must not surface arbitrarily old episodes. const cutoff = dateFetchMoreCutoff( cached, currentCount, windowDays, ); newCount = dateBandCount(cached, currentCount, cutoff); } if (newCount <= currentCount) continue; episodeLoadCount.set(feed.id, newCount); updates.push({ feedId: feed.id, episodes: cached.slice(0, newCount), }); // Yield so the renderer paints between feed computations. await yieldToUI(); } if (updates.length > 0) { const byId = new Map( updates.map((u) => [u.feedId, u.episodes]), ); setFeeds((prev) => prev.map((f) => byId.has(f.id) ? { ...f, episodes: byId.get(f.id)! } : f, ), ); scheduleSaveFeeds(); } } finally { setIsLoadingMore(false); } }; const runAutoDownloadNow = (): void => { runAutoDownload(); }; return { // State feeds, sources: registry.sources, isLoadingMore, /** Resolves once persisted feeds are loaded from disk (before the * background refresh). */ whenReady: () => feedsReady, // Computed getFilteredFeeds, getAllEpisodesChronological, getFeed, findEpisode, hasMoreEpisodes, isLoadingFeeds, // Actions /** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed * record (Discover's episode preview). Pass no feedId to skip the * full-parse cache; the visible window is bounded by the user's * cache preference and `limit`. */ fetchEpisodes, addFeed, removeFeed, removeFeedByUrl, refreshFeed, refreshAllFeeds, loadMoreEpisodes, loadMoreAllFeeds, hasMoreAcrossAll, flushPendingSave, addSource: registry.addSource, toggleSource: registry.toggleSource, updateSource: registry.updateSource, runAutoDownload: runAutoDownloadNow, }; } let feedStoreInstance: ReturnType | null = null; /** Re-exported: refresh-merge tests import it from the store module. */ export { sameRefreshWindow } from "../utils/episode-windows"; export function useFeedStore() { if (!feedStoreInstance) { feedStoreInstance = createFeedStore(); } return feedStoreInstance; }