diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index d834393..79bf477 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -74,6 +74,72 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => { return undefined } +/** Extract the `` blocks from an RSS document (the sync part of + * parsing is bounded to this single regex pass). Exported so the feed store + * can parse episodes incrementally without re-deriving item boundaries. */ +export const getRSSItems = (xml: string): string[] => { + const channel = xml.match(//i)?.[0] ?? xml + return channel.match(//gi) ?? [] +} + +/** Parse a single `` into an Episode. Exported so the feed store can + * parse large feeds in bounded chunks (yielding to the event loop between + * chunks) instead of one synchronous block. */ +export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => { + const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}` + const epDescription = cleanField(getTagValue(item, "description")) + const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) + + // Audio URL + file size + MIME type from + const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) + const audioUrl = enclosure?.[1] ?? "" + const fileSizeStr = getAttr(item, "enclosure", "length") + const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined + const mimeType = getAttr(item, "enclosure", "type") || undefined + + // Duration from + const durationRaw = getTagValue(item, "itunes:duration") + const duration = parseDuration(durationRaw) + + // Episode & season numbers + const episodeNumRaw = getTagValue(item, "itunes:episode") + const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined + const seasonNumRaw = getTagValue(item, "itunes:season") + const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined + + // Episode type & explicit + const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType")) + const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase() + const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined + + // Episode image (itunes:image has href attribute) + const imageUrl = getAttr(item, "itunes:image", "href") || undefined + + const ep: Episode = { + id: `${feedUrl}#${index}`, + podcastId: feedUrl, + title: epTitle, + description: epDescription, + audioUrl, + duration, + pubDate, + } + + // Only set optional fields if present + if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber + if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber + if (episodeType) ep.episodeType = episodeType + if (explicit !== undefined) ep.explicit = explicit + if (imageUrl) ep.imageUrl = imageUrl + if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize + if (mimeType) ep.mimeType = mimeType + + return ep +} + +/** Parse a full RSS document (channel metadata + all episodes). The sync + * whole-feed variant — callers that parse potentially huge feeds on a UI + * thread should prefer the store's chunked incremental parse instead. */ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => { const channel = xml.match(//i)?.[0] ?? xml const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast" @@ -81,58 +147,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes const author = decodeEntities(getTagValue(channel, "itunes:author")) const lastUpdated = new Date() - const items = channel.match(//gi) ?? [] - const episodes = items.map((item, index) => { - const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}` - const epDescription = cleanField(getTagValue(item, "description")) - const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) - - // Audio URL + file size + MIME type from - const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) - const audioUrl = enclosure?.[1] ?? "" - const fileSizeStr = getAttr(item, "enclosure", "length") - const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined - const mimeType = getAttr(item, "enclosure", "type") || undefined - - // Duration from - const durationRaw = getTagValue(item, "itunes:duration") - const duration = parseDuration(durationRaw) - - // Episode & season numbers - const episodeNumRaw = getTagValue(item, "itunes:episode") - const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined - const seasonNumRaw = getTagValue(item, "itunes:season") - const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined - - // Episode type & explicit - const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType")) - const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase() - const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined - - // Episode image (itunes:image has href attribute) - const imageUrl = getAttr(item, "itunes:image", "href") || undefined - - const ep: Episode = { - id: `${feedUrl}#${index}`, - podcastId: feedUrl, - title: epTitle, - description: epDescription, - audioUrl, - duration, - pubDate, - } - - // Only set optional fields if present - if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber - if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber - if (episodeType) ep.episodeType = episodeType - if (explicit !== undefined) ep.explicit = explicit - if (imageUrl) ep.imageUrl = imageUrl - if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize - if (mimeType) ep.mimeType = mimeType - - return ep - }) + const items = getRSSItems(xml) + const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index)) return { id: feedUrl, diff --git a/src/components/GlobalActivityIndicator.tsx b/src/components/GlobalActivityIndicator.tsx new file mode 100644 index 0000000..fa779e6 --- /dev/null +++ b/src/components/GlobalActivityIndicator.tsx @@ -0,0 +1,53 @@ +import { Show } from "solid-js"; +import { useFeedStore } from "@/stores/feed"; +import { useSearchStore } from "@/stores/search"; +import { useDownloadStore } from "@/stores/download"; +import { useActivityStore } from "@/stores/activity"; +import { LoadingIndicator } from "@/components/LoadingIndicator"; + +/** + * GlobalActivityIndicator — one global top-right signal that ANY feed + * refresh, fetch-more, subscribe fetch, search, or download is in flight. + * Per-page spinners are unchanged; this overlays the content row and status + * bar as a single app-wide "something is happening" indicator. + */ +export function GlobalActivityIndicator() { + const feedStore = useFeedStore(); + const searchStore = useSearchStore(); + const downloadStore = useDownloadStore(); + const activity = useActivityStore(); + + /** True while any tracked activity is in flight */ + const isActive = () => + feedStore.isLoadingFeeds() || + feedStore.isLoadingMore() || + searchStore.isSearching() || + downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || + activity.isActive(); + + /** Label priority: downloads in flight > latest tracked activity > + * generic loading (only reachable when an isLoading/isSearching flag is + * on but nothing else is). */ + const label = () => { + const activeCount = downloadStore.getActiveCount(); + const queueLength = downloadStore.getQueue().length; + if (activeCount + queueLength > 0) { + return `Downloading ${activeCount}${ + queueLength > 0 ? ` +${queueLength} queued` : "" + }`; + } + if (activity.isActive()) { + const latest = activity.labels().at(-1); + return `${latest ?? "Loading"}…`; + } + return "Loading…"; + }; + + return ( + + + + + + ); +} diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index 2a8d566..ef1b49f 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -63,6 +63,9 @@ export type PaneRowProps = { /** Number of visible columns. `3` (default) = parent|current|preview; * `2` = parent|current (preview omitted, current grows to fill). */ panes?: 2 | 3; + /** Which sides of the current column's border render. Defaults to + * `["left", "right"]` (the standard focused-list frame). */ + currentBorder?: boolean | BorderSides[]; }; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -181,6 +184,9 @@ export function PaneRow(props: PaneRowProps) { ? PANE_RATIO.current + PANE_RATIO.preview : PANE_RATIO.current, ); + const currentBorder = createMemo( + () => props.currentBorder ?? ["left", "right"], + ); return ( @@ -197,7 +203,7 @@ export function PaneRow(props: PaneRowProps) { grow={currentGrow()} label={() => ""} content={currentContent} - border={["left", "right"]} + border={currentBorder()} scrollFocused={() => focused()} /> {/* ── preview (30%) — hovered-item detail; no border, no header ────── */} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 35798ca..0c18ebe 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -27,6 +27,7 @@ import { TABS } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; import { TabListPane } from "@/components/TabPanel"; import { PaneRow } from "@/components/PaneRow"; +import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator"; export function Shell() { const theme = useTheme(); @@ -396,6 +397,8 @@ export function Shell() { theme={t as any} /> + {/* ── Global activity indicator (top-right overlay) ─────────────────────── */} + ); } diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index f7cb1ad..da058ba 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -236,7 +236,7 @@ function FeedPage() { 0} fallback={ - + - + diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index 63a3866..6bf166b 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -133,6 +133,7 @@ export function PlayerPage() { currentLabel="Player" panes={2} focused={isActive} + currentBorder={["left"]} /> ); } diff --git a/src/stores/activity.ts b/src/stores/activity.ts new file mode 100644 index 0000000..a2341ac --- /dev/null +++ b/src/stores/activity.ts @@ -0,0 +1,75 @@ +/** + * Activity store for PodTUI + * + * Shared leak-proof activity counter: any store can surface "something is + * loading/downloading" to the global top-right indicator. beginActivity + * returns an end token that removes exactly THAT instance, so concurrent + * overlapping activities compose correctly; prefer track() so callers + * cannot strand the counter. + */ + +import { createSignal } from "solid-js"; + +/** Create activity store */ +function createActivityStore() { + const [count, setCount] = createSignal(0); + const [labels, setLabels] = createSignal([]); + + /** Begin a tracked activity and return its end function. Every begin + * MUST be paired with exactly one call of the returned end (via the + * token); prefer track() so the pairing is automatic. Duplicate labels + * are allowed — each end removes exactly one instance (found by + * indexOf). */ + const beginActivity = (label: string): (() => void) => { + setLabels((prev) => [...prev, label]); + setCount((c) => c + 1); + let ended = false; + return () => { + if (ended) return; + ended = true; + setLabels((prev) => { + const idx = prev.indexOf(label); + if (idx === -1) return prev; + const next = [...prev]; + next.splice(idx, 1); + return next; + }); + setCount((c) => Math.max(0, c - 1)); + }; + }; + + /** Track a promise: begin an activity, auto-end when it settles, and + * re-throw on rejection so the caller's error handling is untouched. */ + const track = async (p: Promise, label: string): Promise => { + const end = beginActivity(label); + try { + return await p; + } finally { + end(); + } + }; + + /** True while at least one activity is in flight */ + const isActive = (): boolean => count() > 0; + + return { + // State + count, + labels, + // Actions + beginActivity, + track, + // Getters + isActive, + }; +} + +/** Singleton activity store */ +let activityStoreInstance: ReturnType | null = null; + +export function useActivityStore() { + if (!activityStoreInstance) { + activityStoreInstance = createActivityStore(); + } + return activityStoreInstance; +} diff --git a/src/stores/feed.ts b/src/stores/feed.ts index d03b277..93f33ed 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -10,15 +10,17 @@ 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 { parseRSSFeed } from "../api/rss-parser"; +import { getRSSItems, parseRSSItem } from "../api/rss-parser"; import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver"; import { savePodcastIndexCredentials } from "../utils/source-credentials"; +import { mergeEpisodes } from "../utils/episode-merge"; import { loadFeedsFromFile, saveFeedsToFile, loadSourcesFromFile, saveSourcesToFile, } from "../utils/feeds-persistence"; +import { useActivityStore } from "./activity"; import { useDownloadStore } from "./download"; import { useAppStore } from "./app"; import { DownloadStatus } from "../types/episode"; @@ -29,13 +31,65 @@ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; +/** Per-feed bound on both the cached parse results and the merged in-memory + * window; 500 covers years of a weekly show's history while capping a + * 20-subscription install at 10k episodes. */ +export const MAX_EPISODES_IN_MEMORY = 500; + /** Per-feed fetch timeout — a hung feed must not stall a refresh batch or * the background refresh loop. */ const FETCH_TIMEOUT_MS = 20_000; +/** 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 = 25; + +/** 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); + 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[]) */ const fullEpisodeCache = new Map(); @@ -96,15 +150,42 @@ async function migratePlaintextCredentials( return changed ? migrated : sources; } -/** True when two episode lists hold the same episodes (id-set equality, - * order-insensitive). Refreshes compare fetched content against this so an - * unchanged feed keeps its `lastUpdated` — and therefore its place in the - * "updated" sort — instead of reordering the list on every background - * refresh. */ -function sameEpisodes(a: Episode[], b: Episode[]): boolean { - if (a.length !== b.length) return false; - const ids = new Set(a.map((e) => e.id)); - return b.every((e) => ids.has(e.id)); +/** True when the freshly fetched window matches the corresponding PREFIX of + * the existing episode list (id-set equality, order-insensitive). With + * union semantics the merged list legitimately contains episodes BEYOND the + * fetched window, so unchanged-detection must compare the fetched window + * against the existing list's prefix — comparing full lists would bump + * `lastUpdated` on every refresh. */ +function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean { + if (fetched.length === 0) return true; + const prefix = existing.slice(0, fetched.length); + const ids = new Set(prefix.map((e) => e.id)); + return fetched.every((e) => ids.has(e.id)); +} + +/** Run `fn` over every item with at most `limit` executions in flight — a + * classic worker pool. Workers pull indexes from a shared counter, so the + * first `limit` calls start immediately and each completion frees its slot + * for the next item; results are assembled in INPUT order regardless of + * completion order. A hung `fn` holds at most one slot. */ +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + let i: number; + while ((i = nextIndex++) < items.length) { + results[i] = await fn(items[i]); + } + }, + ); + await Promise.all(workers); + return results; } /** Create feed store */ @@ -122,6 +203,39 @@ function createFeedStore() { const [isLoadingMore, setIsLoadingMore] = createSignal(false); const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); + // ── Debounced persistence ─────────────────────────────────────────────── + /** Trailing-edge debounce window for config.json writes. */ + const SAVE_DEBOUNCE_MS = 250; + /** True when a save is scheduled but has not flushed yet. */ + let savePending = false; + let pendingSaveTimer: ReturnType | null = null; + + /** Schedule a config.json write (trailing edge) — rapid state changes + * (a refresh batch landing feed-by-feed, pin toggles, load-more pages) + * collapse into one final write instead of one file rewrite per step. */ + const scheduleSaveFeeds = (): void => { + savePending = true; + if (pendingSaveTimer) clearTimeout(pendingSaveTimer); + pendingSaveTimer = setTimeout(() => { + pendingSaveTimer = null; + flushPendingSave(); + }, SAVE_DEBOUNCE_MS); + }; + + /** Persist immediately when anything is dirty; exported for tests and + * quit hooks. Cancels a pending debounced save — the state it would + * have written is already reflected in feeds(), so writing now is + * strictly more current. */ + const flushPendingSave = (): void => { + if (pendingSaveTimer) { + clearTimeout(pendingSaveTimer); + pendingSaveTimer = null; + } + if (!savePending) return; + savePending = false; + saveFeeds(feeds()); + }; + /** Get filtered and sorted feeds */ const getFilteredFeeds = (): Feed[] => { let result = [...feeds()]; @@ -230,12 +344,13 @@ function createFeedStore() { }); if (!response.ok) return null; const xml = await response.text(); - const parsed = parseRSSFeed(xml, feedUrl); - const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); + const allEpisodes = sortEpisodesReverseChronological( + await parseEpisodesIncremental(xml, feedUrl), + ); // Cache all parsed episodes for pagination if (feedId) { - fullEpisodeCache.set(feedId, allEpisodes); + fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY)); episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); } @@ -256,45 +371,51 @@ function createFeedStore() { sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC, ): Promise => { - // 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 }; - } + 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; - } + // 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 = await fetchEpisodes( - podcast.feedUrl, - MAX_EPISODES_SUBSCRIBE, - feedId, - ); - const newFeed: Feed = { - id: feedId, - podcast, - episodes: episodes ?? [], - visibility, - sourceId, - lastUpdated: new Date(), - isPinned: false, - }; - setFeeds((prev) => { - const updated = [...prev, newFeed]; - saveFeeds(updated); - return updated; - }); - // Global auto-download: newly subscribed shows join the next pass. - runAutoDownload(); - return newFeed; + const feedId = crypto.randomUUID(); + const episodes = await fetchEpisodes( + podcast.feedUrl, + MAX_EPISODES_SUBSCRIBE, + feedId, + ); + 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 @@ -332,10 +453,13 @@ function createFeedStore() { }; /** Apply a freshly fetched episode list to one feed, bumping `lastUpdated` - * only when the content actually changed (see sameEpisodes). 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. */ + * 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 capped at MAX_EPISODES_IN_MEMORY. 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, @@ -344,66 +468,73 @@ function createFeedStore() { let changed = false; const updated = prev.map((f) => { if (f.id !== feedId) return f; - if (sameEpisodes(f.episodes, episodes)) return f; + const merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY); + if (sameRefreshWindow(f.episodes, episodes)) return f; changed = true; - return { ...f, episodes, lastUpdated: new Date() }; + 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 feed = getFeed(feedId); - if (!feed) return; - const episodes = await fetchEpisodes( - feed.podcast.feedUrl, - 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); - return updated; - }); + const activity = useActivityStore(); + return activity.track((async () => { + const feed = getFeed(feedId); + if (!feed) return; + const episodes = await fetchEpisodes( + feed.podcast.feedUrl, + 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) scheduleSaveFeeds(); + return updated; + }); - // Global auto-download: ensure the N most recent episodes of in-scope - // shows are available offline after every refresh (idempotent). - runAutoDownload(); + // 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 — fetch every feed in parallel, then apply ONE - * atomic update. Per-feed incremental setFeeds re-sorted the list once - * per completion (each refresh bumped lastUpdated and the "updated" sort - * re-ran), which showed up as the list order flapping until the batch - * finished. */ + /** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY + * in-flight requests), and each feed's refreshed episodes are applied + * AS ITS OWN FETCH LANDS (no Promise.all barrier). 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. */ const refreshAllFeeds = async () => { setIsLoadingFeeds(true); try { - const currentFeeds = feeds(); - const results = await Promise.all( - currentFeeds.map(async (feed) => [ - feed.id, - await fetchEpisodes( + await mapWithConcurrency( + feeds(), + FETCH_CONCURRENCY, + async (feed) => { + const episodes = await fetchEpisodes( feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id, - ), - ] as const), - ); - 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); - return updated; - }); + if (!episodes) return; + setFeeds((prev) => { + const updated = applyRefreshedEpisodes(prev, feed.id, episodes); + if (updated !== prev) scheduleSaveFeeds(); + return updated; + }); + }, + ); // 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); } @@ -476,7 +607,10 @@ function createFeedStore() { episodeLoadCount.delete(feedId); setFeeds((prev) => { const updated = prev.filter((f) => f.id !== feedId); - saveFeeds(updated); + // Unsubscribe intent must not sit in the debounce window if the + // process exits — persist the removal immediately. + scheduleSaveFeeds(); + flushPendingSave(); return updated; }); }; @@ -489,7 +623,10 @@ function createFeedStore() { episodeLoadCount.delete(feed.id); setFeeds((prev) => { const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl); - saveFeeds(updated); + // Unsubscribe intent must not sit in the debounce window if + // the process exits — persist the removal immediately. + scheduleSaveFeeds(); + flushPendingSave(); return updated; }); } @@ -501,7 +638,7 @@ function createFeedStore() { const updated = prev.map((f) => f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f, ); - saveFeeds(updated); + scheduleSaveFeeds(); return updated; }); }; @@ -512,7 +649,7 @@ function createFeedStore() { const updated = prev.map((f) => f.id === feedId ? { ...f, isPinned: !f.isPinned } : f, ); - saveFeeds(updated); + scheduleSaveFeeds(); return updated; }); }; @@ -606,16 +743,28 @@ function createFeedStore() { // If no cache, re-fetch and parse the full feed if (!cached) { - const response = await fetch(feed.podcast.feedUrl, { - headers: { - "Accept-Encoding": "identity", - Accept: "application/rss+xml, application/xml, text/xml, */*", - }, - }); - if (!response.ok) return; - const xml = await response.text(); - const parsed = parseRSSFeed(xml, feed.podcast.feedUrl); - cached = parsed.episodes; + try { + const response = await fetch(feed.podcast.feedUrl, { + headers: { + "Accept-Encoding": "identity", + Accept: "application/rss+xml, application/xml, text/xml, */*", + }, + // A hung feed must not stall the load-more path forever — + // mirror fetchEpisodes' per-feed timeout. + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return; + const xml = await response.text(); + 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 and cap it so the + // cache and the pagination window stay newest-first and bounded. + cached = sortEpisodesReverseChronological(cached); + cached = cached.slice(0, MAX_EPISODES_IN_MEMORY); fullEpisodeCache.set(feedId, cached); // Set current load count to match what's already displayed episodeLoadCount.set(feedId, feed.episodes.length); @@ -636,7 +785,7 @@ function createFeedStore() { const updated = prev.map((f) => f.id === feedId ? { ...f, episodes } : f, ); - saveFeeds(updated); + scheduleSaveFeeds(); return updated; }); }; @@ -713,6 +862,7 @@ function createFeedStore() { loadMoreEpisodes, loadMoreAllFeeds, hasMoreAcrossAll, + flushPendingSave, addSource, removeSource, toggleSource, diff --git a/src/utils/config.ts b/src/utils/config.ts index 01bd202..3a497ae 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -93,6 +93,13 @@ export function updateConfig(patch: Partial): void { }); } +/** Resolve once every queued config write has flushed. Tests await this to + * observe the serialized result of pending saveFeedsToFile/updateConfig + * calls before asserting on config.json. */ +export function whenConfigIdle(): Promise { + return writeChain; +} + /** Guards so migration runs exactly once per process. */ let migrationDone = false; let migrationPromise: Promise | null = null; diff --git a/src/utils/episode-merge.ts b/src/utils/episode-merge.ts new file mode 100644 index 0000000..a31ddc4 --- /dev/null +++ b/src/utils/episode-merge.ts @@ -0,0 +1,26 @@ +import type { Episode } from "../types/episode" + +/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST + * (Infinity) so undated episodes float to the top instead of dropping into + * the oldest slot. */ +const ts = (ep: Episode): number => { + const t = ep.pubDate?.getTime() + return t === undefined || Number.isNaN(t) ? Infinity : t +} + +/** + * Union of two episode lists keyed by id — on collision the fetched copy + * wins (fresh metadata). Result is sorted newest-first by pubDate and capped + * at `cap` entries (oldest dropped). Never mutates either input. + */ +export function mergeEpisodes( + existing: Episode[], + fetched: Episode[], + cap: number, +): Episode[] { + const byId = new Map() + for (const ep of existing) byId.set(ep.id, ep) + for (const ep of fetched) byId.set(ep.id, ep) + const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a)) + return sorted.slice(0, cap) +} diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index fca9f5b..dc3f92a 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -4,9 +4,57 @@ */ import { loadConfig, updateConfig } from "./config"; +import { getConfigFilePath } from "./config-dir"; +import { DownloadStatus } from "../types/episode"; +import type { Episode } from "../types/episode"; import type { Feed } from "../types/feed"; import type { PodcastSource } from "../types/source"; +/** Retention window for persisted episodes: older episodes are dropped when + * feeds are written to config.json unless they are completed downloads. */ +export const PERSISTED_WINDOW_DAYS = 30; + +/** True when an episode may be persisted: it is a completed download, or its + * pubDate is missing/invalid (fail-safe: never drop an undatable episode), + * or it falls inside the retention window. */ +export function episodeIsPersistable( + ep: Episode, + downloadedIds: Set, + now: Date, +): boolean { + if (downloadedIds.has(ep.id)) return true; + const t = ep.pubDate?.getTime(); + if (!t || Number.isNaN(t)) return true; + return t >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000; +} + +/** Episode ids of completed downloads, read from downloads.json. In-flight + * downloads are NOT exempted from the retention window — a just-completed + * download is re-included by the next save because the in-memory + * feed.episodes still holds it. Missing/unreadable/invalid file → empty set. */ +async function readDownloadedEpisodeIds(): Promise> { + try { + const file = Bun.file(getConfigFilePath("downloads.json")); + if (!(await file.exists())) return new Set(); + const raw = await file.json(); + if (!Array.isArray(raw)) return new Set(); + const ids = new Set(); + for (const rec of raw) { + if ( + rec && + typeof rec === "object" && + rec.status === DownloadStatus.COMPLETED && + typeof rec.episodeId === "string" + ) { + ids.add(rec.episodeId); + } + } + return ids; + } catch { + return new Set(); + } +} + /** Deserialize date strings back to Date objects in feed data */ function reviveDates(feed: Feed): Feed { return { @@ -23,20 +71,54 @@ function reviveDates(feed: Feed): Feed { }; } -/** Load feeds from config.json */ +/** Load feeds from config.json, pruning episodes outside the retention + * window (completed downloads always kept). When anything was pruned, the + * pruned list is rewritten to config.json (startup cleanup for legacy + * configs). The read path is awaited so the returned value is deterministic. */ export async function loadFeedsFromFile(): Promise { try { const cfg = await loadConfig(); if (!Array.isArray(cfg.feeds)) return []; - return cfg.feeds.map(reviveDates); + const feeds = cfg.feeds.map(reviveDates); + const downloadedIds = await readDownloadedEpisodeIds(); + const now = new Date(); + let prunedAny = false; + const pruned = feeds.map((f) => { + const kept = f.episodes.filter((ep) => + episodeIsPersistable(ep, downloadedIds, now), + ); + if (kept.length !== f.episodes.length) prunedAny = true; + return { ...f, episodes: kept }; + }); + if (prunedAny) { + // Fire-and-forget cleanup rewrite of the legacy config. + saveFeedsToFile(pruned); + } + return pruned; } catch { return []; } } -/** Save feeds to config.json */ +/** Save feeds to config.json, pruning episodes outside the retention window + * (completed downloads always kept). Fire-and-forget: the prune reads + * downloads.json asynchronously, then enqueues the write. On any error the + * UNPRUNED feeds are saved instead, so data is never lost. */ export function saveFeedsToFile(feeds: Feed[]): void { - updateConfig({ feeds }); + (async () => { + try { + const downloadedIds = await readDownloadedEpisodeIds(); + const pruned = feeds.map((f) => ({ + ...f, + episodes: f.episodes.filter((ep) => + episodeIsPersistable(ep, downloadedIds, new Date()), + ), + })); + updateConfig({ feeds: pruned }); + } catch { + updateConfig({ feeds }); /* never lose data on an error path */ + } + })().catch(() => {}); } /** Load sources from config.json */ diff --git a/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md b/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md new file mode 100644 index 0000000..c1b1988 --- /dev/null +++ b/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md @@ -0,0 +1,83 @@ +# 01. Persist only a 30-day episode window, keep downloaded episodes, clean up stale data + +meta: + id: bounded-feed-lifecycle-01 + feature: bounded-feed-lifecycle + priority: P1 + depends_on: [] + tags: [implementation, tests-required] + +objective: + +- Bound what the app writes to `config.json`: each persisted feed keeps only episodes published within the last 30 days, plus any episode whose download is completed — everything older lives in volatile memory only (wired up in task 02). Loading an over-window legacy config must prune it automatically (cleanup on first launch). + +background (read this before touching code): + +- Feeds persist through `src/utils/feeds-persistence.ts`. `saveFeedsToFile(feeds)` is a fire-and-forget wrapper around `updateConfig({ feeds })` in `src/utils/config.ts`, which read-modify-writes the whole `config.json` behind a serialized promise chain (`writeChain`). +- Today `saveFeedsToFile` writes every loaded episode, so `config.json` grows forever (the Feed page's "Fetch More" keeps expanding `feed.episodes` and saving). +- Downloads persist separately in `downloads.json` (same config dir, see `src/utils/config-dir.ts` `getConfigFilePath("downloads.json")`). Each record has `episodeId`, `status`, `feedId`, etc. The `DownloadStatus` enum lives in `src/types/episode.ts` — read it there for the completed member's string value; do NOT hardcode a guessed string. +- `src/stores/feed.ts` calls `saveFeedsToFile` from a module-scope `saveFeeds()` helper. Callers must not change in this task. +- Style: match the file you edit. `feeds-persistence.ts` and `config.ts` are tab-indented WITH semicolons (some other repo files aren't — don't "fix" that anywhere). + +deliverables: + +- `src/utils/feeds-persistence.ts`: + - New exported constant `PERSISTED_WINDOW_DAYS = 30`. + - New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set, now: Date): boolean` — returns `true` when: + - `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode), OR + - `ep.pubDate.getTime() >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000`, OR + - `downloadedIds.has(ep.id)`. + - New (module-private) async helper `readDownloadedEpisodeIds(): Promise>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment. + - `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path). + - `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async). +- `src/utils/config.ts`: + - New exported `whenConfigIdle(): Promise` returning the module-internal `writeChain` promise. Tests need a way to await pending serialized writes; today `updateConfig` hides the chain and tests cannot observe when a write lands. +- `tests/feed-retention.test.ts` (new) — see tests section. + +steps: + +1. Read `src/types/episode.ts` to confirm `DownloadStatus.COMPLETED`'s runtime value and the `Episode` shape (`id`, `pubDate`). +2. Read `src/utils/feeds-persistence.ts` and `src/utils/config.ts` fully (they are short). +3. Add `whenConfigIdle()` to `config.ts` next to `updateConfig`. +4. In `feeds-persistence.ts`: add imports (`getConfigFilePath` from `./config-dir`, `DownloadStatus` and `type Episode` from `../types/episode`), the constant, `episodeIsPersistable`, `readDownloadedEpisodeIds`, then rework `saveFeedsToFile` and `loadFeedsFromFile` per deliverables. Keep `reviveDates` untouched. +5. Ensure `saveFeeds` in `src/stores/feed.ts` still compiles unchanged (signature-compatible). +6. Write `tests/feed-retention.test.ts`, run it, then run the full suite and lint. + +tests: + +- Conventions (copy them): `tests/feed-refresh.test.ts` shows the harness — `mkdtempSync` into `process.env.XDG_CONFIG_HOME` **before** importing anything under test (module-level init reads the config dir), `rmSync` in `afterAll`, tabs/no-semicolon style not required but match repo. +- New `tests/feed-retention.test.ts`: + - Unit (Arrange–Act–Assert) for `episodeIsPersistable`: + - episode 40 days old, not downloaded → `false`. + - episode 40 days old, id in `downloadedIds` → `true`. + - episode 5 days old → `true`. + - episode with `pubDate: new Date(NaN)` → `true` (fail-safe). + - Save-path integration: + - Arrange: write a `downloads.json` in the temp config dir containing one `completed` record for `old-downloaded-id` (include all fields the loader reads in `src/stores/download.ts`'s `DownloadRecord`: at minimum `episodeId`, `feedId`, `status`, `filePath: null`, `downloadedAt: null`, `fileSize: 0`, `error: null`, `audioUrl: ""`, `episodeTitle: ""`). + - Act: call `saveFeedsToFile([feed])` where the feed has three episodes — recent, old-not-downloaded (`id: "old-plain-id"`), old-downloaded (`id: "old-downloaded-id"`). Await `whenConfigIdle()` (plus one more microtask/`await Promise.resolve()` round if the async IIFE resolves after the chain call — flush both). + - Assert: parse `config.json` raw; the feed's persisted `episodes` contain the recent and `old-downloaded-id` episodes and NOT `old-plain-id`. + - Load-path cleanup: + - Arrange: seed `config.json` (write it directly with `Bun.write`) with one feed holding only over-window episodes; no `downloads.json`. + - Act: `await loadFeedsFromFile()`, then `await whenConfigIdle()`. + - Assert: returned feed has zero episodes AND re-reading `config.json` shows the episodes pruned (cleanup rewrite happened). + +acceptance_criteria: + +- `saveFeedsToFile` never writes an episode older than 30 days unless its id is a completed download in `downloads.json`. +- `loadFeedsFromFile` prunes over-window episodes from legacy configs and rewrites `config.json` when it pruned anything. +- Undatable episodes (`pubDate` missing/invalid) are always persisted. +- No call site of `saveFeedsToFile`/`loadFeedsFromFile` needed to change (compatible signatures). +- `bun test tests/feed-retention.test.ts` passes; the existing `bun test` suite passes; `bun run lint` is clean. + +validation: + +- `bun test tests/feed-retention.test.ts` +- `bun test` (full suite — watch `feed-refresh`/`feed-pagination` for regressions) +- `bun run lint` +- Manual smoke (optional): `bun start`, subscribe to any feed, quit, then `cat ~/.config/podtui/config.json | python3 -c "import sys,json; print(max(e['pubDate'] for f in json.load(sys.stdin)['feeds'] for e in f['episodes']))"` and confirm no persisted episode is older than 30 days. + +notes: + +- `updateConfig` captures the patched data eagerly at call time (`JSON.parse(JSON.stringify(patch))`), so pruning in `saveFeedsToFile` before the `updateConfig` call is exactly where the filter must live — filtering later would be silently ineffective for already-queued writes. +- This task intentionally does NOT change in-memory behavior, refresh merging, or cache bounds — that is task 02. If both are worked on in parallel, 02 imports nothing from 01 except the documented window semantics; the module-level contract above is the seam. +- `downloads.json` is written by `src/stores/download.ts` (`saveDownloads`); reading it directly here avoids a store→module import cycle (download.ts already imports the feed store). diff --git a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md new file mode 100644 index 0000000..d668785 --- /dev/null +++ b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md @@ -0,0 +1,79 @@ +# 02. Merge refreshes against the volatile in-memory episode window with bounded per-feed caches + +meta: + id: bounded-feed-lifecycle-02 + feature: bounded-feed-lifecycle + priority: P2 + depends_on: [bounded-feed-lifecycle-01] + tags: [implementation, tests-required] + +objective: + +- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention so memory stops growing unbounded (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched). + +background (read this before touching code): + +- All work lands in `src/stores/feed.ts` plus one new pure-utils module. Current behavior to change: + - `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`. + - `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing). + - `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`. +- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: old episodes stay browsable until the app exits, fetched refreshes never shrink the list. +- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons). + +deliverables: + +- New `src/utils/episode-merge.ts` (pure, store-free, unit-testable): + - `mergeEpisodes(existing: Episode[], fetched: Episode[], cap: number): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; truncated to `cap` entries (the OLDEST are dropped — after sorting, a plain `.slice(0, cap)`). + - Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper). +- `src/stores/feed.ts`: + - New constant `MAX_EPISODES_IN_MEMORY = 500` (comment: per-feed bound on both the cached parse results and the merged in-memory window; 500 covers years of a weekly show's history while capping a 20-subscription install at 10k episodes). + - `fetchEpisodes`: cap what goes into `fullEpisodeCache` — `fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY))` (the array is already sorted newest-first via `sortEpisodesReverseChronological`). The LIMIT window returned to callers is unchanged. + - `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics: + - Compute `merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY)`. + - Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards. + - Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`. + - Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`). + - `loadMoreEpisodesForFeed`: cap the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before capping); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the capped cache. +- `tests/feed-volatile-merge.test.ts` (new) — see tests section. + +steps: + +1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness). +2. Write `src/utils/episode-merge.ts` with `mergeEpisodes`. +3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodes` in `applyRefreshedEpisodes`; cap `fullEpisodeCache` writes in `fetchEpisodes` and `loadMoreEpisodesForFeed`; add `MAX_EPISODES_IN_MEMORY`. +4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact). +5. Write the new tests, run, then full suite + lint. + +tests: + +- New `tests/feed-volatile-merge.test.ts`: + - Pure unit (Arrange–Act–Assert) for `mergeEpisodes`: + - dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title). + - union of disjoint lists sorted by `pubDate` desc. + - cap trimming drops the oldest: `cap=2`, three episodes spanning three days → the two newest survive. + - input arrays not mutated. + - Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers): + - Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare). + - Bounded cache: serve 600 items (generate programmatically), refresh, then `hasMoreEpisodes` grows only to the cap: loop `loadMoreEpisodes` until it returns false and assert total loaded ≤ `MAX_EPISODES_IN_MEMORY` (import the constant from the store module if exported, else assert `=== 500`). +- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`. + +acceptance_criteria: + +- A refresh never removes an episode that was visible before the refresh during the same session. +- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved). +- Per-feed cached/parsed episodes never exceed `MAX_EPISODES_IN_MEMORY`; `loadMore` stops (hasMore → false) at the cap. +- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and can surface over-30-day episodes in volatile memory. +- `bun test` full suite passes; `bun run lint` clean. + +validation: + +- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts` +- `bun test` +- `bun run lint` +- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the deep pages stay; quit and relaunch — deep (over-30-day) pages are gone from the list but fetch-more brings them back. + +notes: + +- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01. +- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the cap belongs at the two write sites named in deliverables, not in a wrapper. +- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today. diff --git a/tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md b/tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md new file mode 100644 index 0000000..c8f5c93 --- /dev/null +++ b/tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md @@ -0,0 +1,84 @@ +# 03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves + +meta: + id: bounded-feed-lifecycle-03 + feature: bounded-feed-lifecycle + priority: P1 + depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02] + tags: [implementation, tests-required] + +objective: + +- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a `Promise.all` barrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, and `config.json` writes (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window. + +background (read this before touching code): + +- Work lands in `src/stores/feed.ts` only (plus its tests). Current posture: + - `refreshAllFeeds()` fires `fetchEpisodes` for every feed at once via `Promise.all` and applies results in ONE `setFeeds` at the end — the user sees nothing until the slowest feed resolves or hits `FETCH_TIMEOUT_MS` (20s). + - `parseEpisodesIncremental` already chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path. + - `loadMoreEpisodesForFeed`'s cold-cache refetch has NO timeout (copy the `AbortSignal.timeout(FETCH_TIMEOUT_MS)` pattern from `fetchEpisodes`). + - Persistence: `saveFeeds(updated)` → `saveFeedsToFile` → `updateConfig`, a serialized full-file read-`JSON.parse`-stringify-`Bun.write` chain in `src/utils/config.ts`. Called from `refreshFeed`, `refreshAllFeeds`, `loadMoreEpisodesForFeed`, `addFeed`, `removeFeed*`, `updateFeed`, `togglePinned`. + - The boot IIFE calls `refreshAllFeeds()` right after `loadFeedsFromFile()` — this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves. + - Single feed `refreshFeed` applies its own `setFeeds` immediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path. +- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through `applyRefreshedEpisodes`/`mergeEpisodes` (02). +- Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers `// ── Name ──…` per repo convention. +- Tests here use `vi.useFakeTimers()` — `setTimeout`-based debounce must therefore be advanced with `vi.advanceTimersByTime` in tests; don't use `queueMicrotask`-style scheduling for the debounce. + +deliverables: + +- `src/stores/feed.ts`: + - New constant `FETCH_CONCURRENCY = 4` (comment: bounds simultaneous RSS requests; a hung feed burns at most one slot for `FETCH_TIMEOUT_MS`). + - New module-level async helper `mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise` — classic worker-pool: `limit` workers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test. + - Rewritten `refreshAllFeeds()`: + - `setIsLoadingFeeds(true)` … `finally setIsLoadingFeeds(false)` as today. + - Process feeds through `mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...)`. + - Inside the per-feed callback: `fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id)`; if non-null, immediately `setFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; })`. Failed feeds (null) stay untouched, as today. + - After all workers settle: ONE `runAutoDownload()` (as today), and `flushPendingSave()` (below) so a refresh batch always ends with a persisted write when anything changed. + - Debounced save plumbing (module scope, replacing direct calls): + - `let pendingSaveTimer: ReturnType | null = null; const SAVE_DEBOUNCE_MS = 250;` + - `scheduleSaveFeeds()` — after a state-changing update, mark dirty: set a `savePending = true` flag and (re)arm the trailing timer to fire `flushPendingSave()`. + - `flushPendingSave()` — if `savePending`, snapshot `feeds()`, call `saveFeeds(snapshot)`, clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write). + - Convert ALL direct `saveFeeds(updated)` / `saveFeeds(newList)` call sites inside `setFeeds` callbacks to `scheduleSaveFeeds()` EXCEPT `removeFeed`/`removeFeedByUrl`, which must call both `scheduleSaveFeeds()` AND `flushPendingSave()` (an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection. + - `loadMoreEpisodesForFeed`: add `signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)` to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroring `fetchEpisodes`). +- `tests/feed-nonblocking.test.ts` (new) — see tests section. + +steps: + +1. Read `src/stores/feed.ts` and confirm tasks 01/02 are merged (`episodeIsPersistable` in `src/utils/feeds-persistence.ts`, `mergeEpisodes` in `src/utils/episode-merge.ts`). +2. Add `FETCH_CONCURRENCY`, `mapWithConcurrency`, and the debounce plumbing. +3. Rewrite `refreshAllFeeds` per deliverables; convert the save call sites. +4. Add the fetch timeout to `loadMoreEpisodesForFeed`'s cold refetch. +5. Export `flushPendingSave` from the store's return object (Actions section). +6. Write `tests/feed-nonblocking.test.ts`; run new + existing feed tests; full suite; lint. + +tests: + +- Harness conventions: copy `tests/feed-refresh.test.ts` (temp `XDG_CONFIG_HOME` BEFORE store imports; `Bun.serve` port 0; `vi.useFakeTimers()` in `beforeEach`). Note `vi.advanceTimersByTime(...)` also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment on `yieldToUI`). +- New `tests/feed-nonblocking.test.ts`: + - Concurrency bound: server records concurrent in-flight requests (increment on entry, `await new Promise(r => setTimeout(r, 50_000))` under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates with `vi.advanceTimersByTime` after asserting). Register 10 feeds; start `refreshAllFeeds()` (don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion. + - Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's `lastUpdated`/episodes already updated in `feeds()` BEFORE the slow feed resolves (this is the acceptance proof the `Promise.all` barrier is gone). Then release the slow gate and assert both applied. + - Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed; `await` both, then `vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)`; read raw `config.json` ONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle against `updateConfig`'s chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce, `config.json` must NOT yet contain the new episodes; after, it must.) + - `flushPendingSave`: refresh with changed content, call `store.flushPendingSave()` without advancing timers, assert `config.json` already contains the new episode. +- Existing suites must pass unchanged: `feed-refresh.test.ts`, `feed-pagination.test.ts`, `feed-volatile-merge.test.ts`, `feed-refresh-spinner.test.tsx`, `restore-session.test.ts`. + +acceptance_criteria: + +- During a refresh batch, no more than `FETCH_CONCURRENCY` HTTP requests are ever in flight. +- Each feed's refreshed episodes are visible in `feeds()` as soon as its own fetch resolves — no waiting for the slowest feed. +- Writes to `config.json` are trailing-edge debounced: rapid successive updates produce one final write after the settle window, and `flushPendingSave()` persists immediately. +- `loadMoreEpisodesForFeed`'s refetch aborts at `FETCH_TIMEOUT_MS` instead of hanging forever. +- `bun test` full suite passes; `bun run lint` clean. + +validation: + +- `bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.ts` +- `bun test` +- `bun run lint` +- Manual smoke: `bun start` with several subscriptions; hold `j` during the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted. + +notes: + +- The background refresh timer (`scheduleNextRefresh`) already skips ticks while `isLoadingFeeds()` is true — unchanged. +- Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites. +- `mapWithConcurrency` is generic; keep it module-private in `feed.ts` (no premature new util file). +- `updateConfig` snapshots its patch at call time (`JSON.parse(JSON.stringify(patch))`), so debouncing by delaying the `saveFeeds` CALL is correct — a pending write always serializes the latest feeds it was handed. diff --git a/tasks/bounded-feed-lifecycle/04-global-activity-indicator.md b/tasks/bounded-feed-lifecycle/04-global-activity-indicator.md new file mode 100644 index 0000000..847507d --- /dev/null +++ b/tasks/bounded-feed-lifecycle/04-global-activity-indicator.md @@ -0,0 +1,77 @@ +# 04. Add a shared activity store and global top-right loading indicator + +meta: + id: bounded-feed-lifecycle-04 + feature: bounded-feed-lifecycle + priority: P2 + depends_on: [bounded-feed-lifecycle-03] + tags: [implementation, tests-required] + +objective: + +- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere. + +background (read this before touching code): + +- `src/components/Shell.tsx` renders the whole chrome: one full-width content row (`LayerGraph[nav.activeTab()]()` / `PaneRow`) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentui `box` supports `position="absolute"`, `top`, `right`). +- Existing activity signals (read them, don't recreate per-store bookkeeping): `useFeedStore().isLoadingFeeds()` / `.isLoadingMore()`; `useSearchStore().isSearching()` (`src/stores/search.ts`); `useDownloadStore().getActiveCount()` and `.getQueue().length` (`src/stores/download.ts`). Gaps these don't cover: single `refreshFeed`, `addFeed`'s subscribe fetch, iTunes feed resolution inside `addFeed` — hence the activity store. +- `src/components/LoadingIndicator.tsx` is the braille spinner (prop `label?: string`); reuse it inside the overlay. +- Activity tracking must be leak-proof: every `begin` paired with an `end` via a token, PLUS a `track(promise, label)` helper that auto-ends on settle so callers can't strand the counter. +- Task 03 added the incremental per-feed apply inside `refreshAllFeeds`; wire activity around the whole batch ( `isLoadingFeeds` already brackets it — prefer reusing the signal, adding explicit `begin/end` ONLY where no signal exists). +- Style: Solid + `@opentui/solid` JSX (no `className`; props like `fg`, `paddingRight`, `position`); store files tab-indented with semicolons; components match `LoadingIndicator.tsx` conventions. Style imports use `@/` alias in components, relative paths in stores. + +deliverables: + +- New `src/stores/activity.ts`: + - Signals: `count` (number), `labels` (string[]). + - Actions: `beginActivity(label: string): () => void` (returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed), `track(p: Promise, label: string): Promise` (begins, ends in `finally`, re-throws). + - Computed: `isActive(): boolean` (`count() > 0`). + - Singleton + `useActivityStore()` accessor, mirroring `src/stores/download.ts`'s module pattern. +- Wire the gaps in `src/stores/feed.ts` (only where no existing signal covers the operation): + - `refreshFeed`: `await activity.track(...)` around the fetch+apply, label `"Refreshing"`. + - `addFeed`: wrap the directory-resolve + `fetchEpisodes` stretch, label `"Subscribing"`. + - Do NOT wrap `refreshAllFeeds`/`loadMoreEpisodes*` — `isLoadingFeeds`/`isLoadingMore` already cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap). +- New `src/components/GlobalActivityIndicator.tsx`: + - Computes active state from: `feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive()`. + - Label selection: downloads in flight → `Downloading N` (+`M queued` when queue non-empty); else the activity store's latest label + `…` (e.g. `Refreshing…`); else `Loading…`. + - Renders `` inside ``; renders nothing (returns `null`) when inactive so it never eats layout when idle. +- `src/components/Shell.tsx`: mount `` as the LAST child of the root `` (after the content row, bottom bar, and help overlay so it paints on top). +- `tests/global-activity-indicator.test.tsx` (new) — see tests section. + +steps: + +1. Read `src/stores/download.ts`, `src/stores/search.ts`, `src/components/LoadingIndicator.tsx`, and the render JSX of `src/components/Shell.tsx`. +2. Write `src/stores/activity.ts` (small; ~60 lines). +3. Wire `refreshFeed`/`addFeed` in `src/stores/feed.ts` via `useActivityStore().track(...)`. Import cycle note: `activity.ts` must import NOTHING from other stores (pure counter) so `feed.ts` importing it is safe. +4. Write `src/components/GlobalActivityIndicator.tsx`; mount it in `Shell.tsx` last (paints on top). +5. Write tests; run new tests, full suite, lint; manual smoke per validation. + +tests: + +- New `tests/global-activity-indicator.test.tsx` (component-test conventions: copy the render harness from `tests/feed-refresh-spinner.test.tsx` — temp `XDG_CONFIG_HOME` before imports; if a jsdom-like setup is used there, reuse it as-is): + - Activity store unit asserts: two `begin`s → `isActive()` true; ending one → still true; ending both → false. `track(failingPromise)` still decrements (rejects propagate, counter returns to baseline). + - Component asserts: render `` in isolation — + - idle → no text rendered; + - `useActivityStore().beginActivity("Refreshing")` → spinner/label present in rendered output; matching end → gone; + - with the download store: enqueue via `downloadStore.startDownload`-equivalent the way `tests/download-unsubscribed.test.ts` does (assert indicator renders while `getActiveCount() + queue > 0`); skip actual network by following that test's existing mocking pattern. +- Existing suites must pass: `feed-refresh-spinner.test.tsx` (per-page spinners unchanged), full `bun test`. + +acceptance_criteria: + +- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active. +- Counter never strands: every completed/failed tracked operation returns `isActive()` to its prior value (proven by the `track` rejection test). +- Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout. +- `bun test` full suite passes; `bun run lint` clean. + +validation: + +- `bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsx` +- `bun test` +- `bun run lint` +- Manual smoke: `bun start`; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) press `r` on Feed — spinner appears; (c) download an episode from Search — `Downloading` label shows while the transfer runs; (d) leave idle — top-right is empty. + +notes: + +- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals. +- The overlay intentionally does NOT replace per-pane spinners (`Refreshing…` in Feed/MyShows/Discover/Search stay) — removing those is out of scope. +- If `position="absolute"` proves unavailable for text-draw ordering in `@opentui/solid`, the fallback is a dedicated 1-row header (`height={1}`) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message. diff --git a/tasks/bounded-feed-lifecycle/README.md b/tasks/bounded-feed-lifecycle/README.md new file mode 100644 index 0000000..fe6f114 --- /dev/null +++ b/tasks/bounded-feed-lifecycle/README.md @@ -0,0 +1,27 @@ +# Bounded Feed Lifecycle + +Objective: Bound feed episode storage to a rolling 30-day persisted window (older episodes volatile-only unless downloaded), keep feed loading nonblocking, and surface all load/download activity in a global top-right indicator. + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks + +- [x] 01 — persisted-retention-window → `01-persisted-retention-window.md` +- [x] 02 — volatile-episode-merge → `02-volatile-episode-merge.md` +- [x] 03 — nonblocking-feed-refresh → `03-nonblocking-feed-refresh.md` +- [x] 04 — global-activity-indicator → `04-global-activity-indicator.md` + +Dependencies + +- 02 depends on 01 (the volatile merge preserves exactly what 01 drops from disk) +- 03 depends on 01 (debounced persistence layers onto the pruning save path) +- 03 depends on 02 (incremental per-feed apply consumes the merge helper from 02) +- 04 depends on 03 (the indicator subscribes to the activity wiring added across refresh/load-more paths in 03) + +Exit criteria + +- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch. +- In-memory retention is capped per feed; episodes aged out of the persisted window remain browsable within the session and are re-fetchable via fetch-more after a restart. +- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout. +- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise. +- `bun test` and `bun run lint` pass. diff --git a/tests/feed-nonblocking.test.ts b/tests/feed-nonblocking.test.ts new file mode 100644 index 0000000..b57098f --- /dev/null +++ b/tests/feed-nonblocking.test.ts @@ -0,0 +1,323 @@ +/** + * Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle + * feature. + * + * Pins the contracts that make a refresh batch feel non-blocking: + * + * 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS + * requests in flight — a worker pool bounds the batch instead of + * Promise.all firing every feed at once. + * 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS — + * the old Promise.all barrier is gone, so a slow feed no longer hides + * the fast feeds' fresh episodes. + * 3. config.json writes are trailing-edge debounced (rapid changes + * collapse into one final write) and flushPendingSave() persists + * immediately, without waiting out the debounce window. + * + * Polling note (why the polls below use setImmediate, not microtasks): + * vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the + * debounce is driven with vi.advanceTimersByTime. But a poll loop of pure + * microtask turns (`await Promise.resolve()`) can NEVER observe an + * in-flight refresh: it keeps the microtask queue non-empty, the event + * loop's poll phase is never reached, and Bun.serve never even receives + * the fetch (verified empirically). setImmediate is a real macrotask that + * fake timers do NOT trap, and it lets the socket I/O progress — each + * `tick()` below is one bounded event-loop turn. No real sleeps anywhere. + */ + +import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config dir at a throwaway directory BEFORE importing the stores +// (their module-level init reads it). +const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-")); +process.env.XDG_CONFIG_HOME = configHome; + +import { useFeedStore } from "../src/stores/feed"; +import type { Podcast } from "../src/types/podcast"; +import { whenConfigIdle } from "../src/utils/config"; + +interface ServedEpisode { + title: string; + date: string; +} + +let server: ReturnType | null = null; +let servedEpisodes: ServedEpisode[] = []; + +/** Per-pathname request gates: while a path has an unresolved gate, the + * server parks that request until the test resolves it. */ +let gates = new Map; resolve: () => void }>(); +/** Requests currently inside the fetch handler (entered, not yet answered). */ +let inFlight = 0; +/** High-water mark of `inFlight` — the concurrency-bound assertion source. */ +let maxConcurrent = 0; + +// Bun runs test files in ONE process, so the store singleton is shared with +// other test files. Track the feeds we add and remove them in afterAll so +// whichever file runs next sees a pristine store. +const addedFeedIds: string[] = []; +/** Feed created by the debounce test, reused by the flushPendingSave test. */ +let debounceFeedId = ""; + +/** XML for the current served episode list (episode ids = feedUrl#index). */ +function feedXml(episodes: ServedEpisode[], origin: string): string { + const items = episodes + .map( + (ep, i) => ` + ${ep.title} + ${ep.date} + +`, + ) + .join("\n"); + return ` + +Non-Blocking Test Show +Non-blocking refresh test feed +${items} +`; +} + +const makePodcast = (feedUrl: string): Podcast => ({ + id: feedUrl, + title: "Non-Blocking Test Show", + description: "Non-blocking refresh test feed", + author: "tester", + feedUrl, + lastUpdated: new Date(), + isSubscribed: true, +}); + +/** Park a request path behind an unresolved gate. */ +function setGate(path: string): void { + const { promise, resolve } = Promise.withResolvers(); + gates.set(path, { gate: promise, resolve }); +} + +/** Resolve every gate currently set. */ +function releaseAllGates(): void { + for (const { resolve } of gates.values()) resolve(); + gates.clear(); +} + +/** One real macrotask turn — see the polling note in the header. */ +const tick = (): Promise => { + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + return promise; +}; + +/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate + * each). Returns whether the condition held by the deadline. */ +async function pollUntil( + cond: () => boolean, + iterations = 500, +): Promise { + for (let i = 0; i < iterations; i++) { + if (cond()) return true; + await tick(); + } + return cond(); +} + +/** Raw config.json text ("" when the file does not exist yet). */ +const readConfigRaw = (): Promise => + Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json")) + .text() + .catch(() => ""); + +beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + inFlight++; + if (inFlight > maxConcurrent) maxConcurrent = inFlight; + try { + const gate = gates.get(url.pathname); + if (gate) await gate.gate; + if (url.pathname.endsWith(".xml")) { + return new Response(feedXml(servedEpisodes, url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }); + } + return new Response("not found", { status: 404 }); + } finally { + inFlight--; + } + }, + }); +}); + +beforeEach(() => { + vi.useFakeTimers(); + gates.clear(); + inFlight = 0; + maxConcurrent = 0; +}); + +afterAll(() => { + vi.useRealTimers(); + const store = useFeedStore(); + for (const id of addedFeedIds) store.removeFeed(id); + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); +}); + +test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => { + const store = useFeedStore(); + servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }]; + const urls = Array.from( + { length: 10 }, + (_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`, + ); + const ids: string[] = []; + for (const url of urls) { + const feed = await store.addFeed(makePodcast(url), "test-source"); + expect(feed).not.toBeNull(); + ids.push(feed!.id); + addedFeedIds.push(feed!.id); + } + + // Gate every path so the batch's requests pile up at the server. addFeed + // ran sequentially above (its own fetches never exceed 1 in flight), so + // the counter below measures the batch alone. + for (const url of urls) setGate(new URL(url).pathname); + inFlight = 0; + maxConcurrent = 0; + + const refreshPromise = store.refreshAllFeeds(); // NOT awaited + const sawBound = await pollUntil(() => maxConcurrent >= 4); + expect(sawBound).toBe(true); + // The worker pool caps the batch at 4 — exactly 4 gated requests are + // parked (nothing has been released, so nothing completed yet), and + // nothing may exceed the bound, now or as the batch drains. + expect(maxConcurrent).toBe(4); + expect(maxConcurrent).toBeLessThanOrEqual(4); + + releaseAllGates(); + await refreshPromise; + expect(maxConcurrent).toBeLessThanOrEqual(4); + for (const id of ids) { + expect(store.getFeed(id)!.episodes.length).toBe(1); + } +}); + +test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => { + const store = useFeedStore(); + servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }]; + const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`; + const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`; + const a = await store.addFeed(makePodcast(aUrl), "test-source"); + const b = await store.addFeed(makePodcast(bUrl), "test-source"); + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + const aId = a!.id; + const bId = b!.id; + addedFeedIds.push(aId, bId); + + // A new episode appears for both feeds; B's fetch is parked at the + // server, A's is not. + servedEpisodes = [ + { title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }, + { title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" }, + ]; + setGate(new URL(bUrl).pathname); + + const beforeA = store.getFeed(aId)!.lastUpdated.getTime(); + const beforeB = store.getFeed(bId)!.lastUpdated.getTime(); + // Advance the (mocked) clock so the refresh's `new Date()` lastUpdated + // bump is observably greater than beforeA (the fake clock otherwise + // never moves — same pattern as feed-refresh.test.ts). + vi.advanceTimersByTime(60_000); + const refreshPromise = store.refreshAllFeeds(); // NOT awaited + + const applied = await pollUntil( + () => store.getFeed(aId)!.lastUpdated.getTime() > beforeA, + ); + expect(applied).toBe(true); + // A's refreshed window is visible in feeds() while B is STILL gated — + // the proof that per-feed results apply as they land. + expect(store.getFeed(aId)!.episodes.length).toBe(2); + expect(store.getFeed(bId)!.episodes.length).toBe(1); + expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB); + + releaseAllGates(); + await refreshPromise; + expect(store.getFeed(aId)!.episodes.length).toBe(2); + expect(store.getFeed(bId)!.episodes.length).toBe(2); +}); + +test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => { + const store = useFeedStore(); + servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }]; + const url = `http://127.0.0.1:${server!.port}/debounce.xml`; + const feed = await store.addFeed(makePodcast(url), "test-source"); + expect(feed).not.toBeNull(); + debounceFeedId = feed!.id; + addedFeedIds.push(debounceFeedId); + + servedEpisodes = [ + { title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }, + { title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" }, + ]; + await store.refreshFeed(debounceFeedId); + + servedEpisodes = [ + { title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }, + { title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" }, + { title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" }, + ]; + await store.refreshFeed(debounceFeedId); + expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3); + + // No timer advanced: the debounced saves have NOT fired — the refreshed + // episodes exist only in memory (await whenConfigIdle first so a + // straggler write from an earlier test cannot race this read). + await whenConfigIdle(); + const before = await readConfigRaw(); + expect(before).not.toContain("Deb Ep 1"); + expect(before).not.toContain("Deb Ep 2"); + + // SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here). + vi.advanceTimersByTime(250); + await whenConfigIdle(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await whenConfigIdle(); + + // One read, both refreshed episodes: the two refreshes collapsed into a + // single trailing-edge write. + const after = await readConfigRaw(); + expect(after).toContain("Deb Ep 1"); + expect(after).toContain("Deb Ep 2"); +}); + +test("flushPendingSave persists immediately, without waiting out the debounce", async () => { + const store = useFeedStore(); + // Same feed as the debounce test (still in the singleton): serve a 4th + // episode and refresh — the save is scheduled, then flushed by hand. + servedEpisodes = [ + { title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }, + { title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" }, + { title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" }, + { title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" }, + ]; + await store.refreshFeed(debounceFeedId); + expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4); + + // No advanceTimersByTime: flushPendingSave must write right now. + store.flushPendingSave(); + await whenConfigIdle(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await whenConfigIdle(); + + const raw = await readConfigRaw(); + expect(raw).toContain("Deb Ep 3"); +}); diff --git a/tests/feed-refresh-spinner.test.tsx b/tests/feed-refresh-spinner.test.tsx new file mode 100644 index 0000000..21a6396 --- /dev/null +++ b/tests/feed-refresh-spinner.test.tsx @@ -0,0 +1,161 @@ +/** + * FeedPage refresh spinner — while feeds are being fetched (manual `r` and + * the background refresh timer both route through refreshAllFeeds → + * isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode + * list, horizontally centered in the current pane. + * + * The refresh is left in flight on purpose (the test server delays its + * response) so the loading state is visible in the captured frame. + */ + +import { test, expect, beforeAll, afterAll } from "bun:test"; +import type { Server } from "bun"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config dir at a throwaway directory BEFORE importing the stores +// (their module-level init reads it) and silence the audio backend. +const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-")); +process.env.XDG_CONFIG_HOME = configHome; +process.env.PODTUI_AUDIO_BACKEND = "none"; + +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { NavigationProvider } from "../src/context/NavigationContext"; +import { FeedPage } from "../src/pages/Feed/FeedPage"; +import { useFeedStore } from "../src/stores/feed"; +import type { Podcast } from "../src/types/podcast"; + +// The LoadingIndicator glyph cycle. +const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/; + +type Frame = { cols: number; lines: { spans: { text: string }[] }[] }; +const frameLines = (f: Frame): string[] => + f.lines.map((l) => l.spans.map((s) => s.text).join("")); + +let server: Server | null = null; +/** Response delay (ms) for the next fetch — 0 during setup, >0 while the + * refresh is in flight so the loading state is observable. */ +let delayMs = 0; +let feedUrl = ""; +let feedId = ""; + +/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */ +function feedXml(origin: string): string { + const items = Array.from({ length: 3 }, (_, i) => ` + Spin Ep ${3 - i} + ${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()} + +`).join("\n"); + return ` + +Spinner Show +spinner test feed +${items} +`; +} + +beforeAll(async () => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (!url.pathname.endsWith(".xml")) { + return new Response("not found", { status: 404 }); + } + const { promise, resolve } = Promise.withResolvers(); + setTimeout( + () => + resolve( + new Response(feedXml(url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }), + ), + delayMs, + ); + return promise; + }, + }); + const podcast: Podcast = { + id: "", + title: "Spinner Show", + description: "spinner test feed", + author: "tester", + feedUrl: "", + lastUpdated: new Date(), + isSubscribed: true, + }; + feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`; + const store = useFeedStore(); + const feed = await store.addFeed( + { ...podcast, feedUrl }, + "test-source", + ); + feedId = feed!.id; +}); + +afterAll(async () => { + const store = useFeedStore(); + store.removeFeed(feedId); + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); +}); + +test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => { + const store = useFeedStore(); + const setup = await testRender( + () => ( + + + + + + ), + { width: 100, height: 30, useThread: false }, + ); + + // Settle until the episode list is mounted. + let lines: string[] | null = null; + for (let i = 0; i < 40 && !lines; i++) { + await setup.renderOnce(); + const ls = frameLines(setup.captureSpans() as unknown as Frame); + if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls; + else await new Promise((r) => setTimeout(r, 50)); + } + if (!lines) throw new Error("FeedPage did not render episodes before timeout"); + + // Kick off a refresh and leave it in flight: isLoadingFeeds flips true + // synchronously, so the very next frame shows the spinner. + delayMs = 400; + const refreshing = store.refreshAllFeeds(); + await setup.renderOnce(); + const loading = frameLines(setup.captureSpans() as unknown as Frame); + + // Locate the spinner row and the current pane's borders ("│" columns; + // only the current pane is bordered in PaneRow). + const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l)); + expect(spinnerRow).toBeGreaterThan(-1); + + const spinnerCol = loading[spinnerRow].search(SPINNER_RE); + const borderCols = loading + .map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : [])) + .flat() + .filter((x) => x >= 0); + const paneLeft = Math.min(...borderCols); + const paneRight = Math.max(...borderCols); + const paneCenter = (paneLeft + paneRight) / 2; + expect(paneLeft).toBeGreaterThan(0); // borders actually found + + // Bottom of the list: below the last episode row. + const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep")); + expect(spinnerRow).toBeGreaterThan(lastEpRow); + + // Horizontally centered in the current pane (not left-padded). + expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8); + + // Let the refresh finish so teardown is clean. + delayMs = 0; + await refreshing; + setup.renderer.destroy(); +}); diff --git a/tests/feed-refresh.test.ts b/tests/feed-refresh.test.ts index 32a7759..a0ec0af 100644 --- a/tests/feed-refresh.test.ts +++ b/tests/feed-refresh.test.ts @@ -137,6 +137,7 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => { test("a failed refresh does not wipe the feed's episodes", async () => { const store = useFeedStore(); + const savedEpisodes = servedEpisodes; 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"); @@ -158,6 +159,11 @@ test("a failed refresh does not wipe the feed's episodes", async () => { failPath = null; store.removeFeed(feedId); + // Restore the shared served content: with union merge semantics (volatile + // episodes survive refreshes) this feed keeps its larger in-memory window, + // so later tests must serve the same episodes they added — a shrink here + // would make the next test's "unchanged" refresh genuinely different. + servedEpisodes = savedEpisodes; }); test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => { @@ -183,3 +189,44 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]); } }); + +test("refresh parses in bounded chunks, yielding to the event loop between them", async () => { + const store = useFeedStore(); + // 60 episodes: a chunked parse (25/chunk) must yield between chunks; a + // monolithic parse would complete without yielding at all. + servedEpisodes = Array.from({ length: 60 }, (_, i) => ({ + title: `Ep ${60 - i}`, + date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + const feedId = feed!.id; + expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window + + // Count event-loop yields during the refresh: each parse-chunk boundary + // posts through a MessageChannel (the yield primitive in feed.ts — the + // one macrotask turn bun's fake timers do not trap, which also pins that + // the yield works under fake timers). This runs under fake timers like + // the other tests; a setTimeout-based yield would deadlock here. + const OriginalMessageChannel = globalThis.MessageChannel; + let posts = 0; + globalThis.MessageChannel = class extends OriginalMessageChannel { + constructor() { + super(); + posts++; + } + }; + try { + vi.advanceTimersByTime(60_000); + await store.refreshFeed(feedId); + } finally { + globalThis.MessageChannel = OriginalMessageChannel; + } + + expect(posts).toBeGreaterThan(0); + expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window + + // Leave the shared singleton as we found it (see the addedFeedIds note + // in feed-pagination.test.ts — bun runs test files in one process). + store.removeFeed(feedId); +}); diff --git a/tests/feed-retention.test.ts b/tests/feed-retention.test.ts new file mode 100644 index 0000000..53f67e1 --- /dev/null +++ b/tests/feed-retention.test.ts @@ -0,0 +1,286 @@ +/** + * Bounded-feed-lifecycle persistence tests — task 01 (retention window). + * + * Pins the persistence contract: + * 1. saveFeedsToFile never writes an episode older than PERSISTED_WINDOW_DAYS + * unless its id is a completed download in downloads.json. + * 2. loadFeedsFromFile prunes over-window episodes from legacy configs and + * rewrites config.json when it pruned anything. + * 3. Undatable episodes (missing/invalid pubDate) are always persisted. + * + * The async saveFeedsToFile IIFE reads downloads.json then enqueues an + * updateConfig write on the serialized write chain, so assertions wait via + * whenConfigIdle() + a short real-timer settle (no fake timers here — see + * settleWrites). + */ + +import { test, expect, afterAll } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config dir at a throwaway directory BEFORE importing anything +// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent +// with the store test harness). Do NOT import the feed store — its module +// boot IIFE would hit the network. +const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-")); +process.env.XDG_CONFIG_HOME = configHome; + +import { + PERSISTED_WINDOW_DAYS, + episodeIsPersistable, + loadFeedsFromFile, + saveFeedsToFile, +} from "../src/utils/feeds-persistence"; +import { whenConfigIdle } from "../src/utils/config"; +import { FeedVisibility } from "../src/types/feed"; +import type { Feed } from "../src/types/feed"; +import type { Episode } from "../src/types/episode"; + +const configJsonPath = join(configHome, "podtui", "config.json"); +const downloadsJsonPath = join(configHome, "podtui", "downloads.json"); + +/** Milliseconds in one day — mirrors the window math in feeds-persistence. */ +const DAY = 24 * 3600 * 1000; + +function makeEpisode(partial: Partial & { id: string }): Episode { + return { + podcastId: "feed-1", + title: partial.id, + description: "", + audioUrl: `https://example.com/audio/${partial.id}.mp3`, + duration: 600, + pubDate: new Date(), + ...partial, + }; +} + +function makeFeed(episodes: Episode[]): Feed { + return { + id: "feed-1", + podcast: { + id: "feed-1", + title: "Retention Show", + description: "Retention test feed", + author: "tester", + feedUrl: "https://example.com/feed.xml", + lastUpdated: new Date(), + isSubscribed: true, + }, + episodes, + visibility: FeedVisibility.PUBLIC, + sourceId: "source-1", + lastUpdated: new Date(), + isPinned: false, + }; +} + +function delay(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + +/** + * Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is + * not awaitable: it reads downloads.json first and only THEN enqueues its + * write on the serialized chain, so the first whenConfigIdle() may observe + * the chain BEFORE the write is queued. A real-timer settle is the only way + * to let the IIFE's async read land without fake timers (which would stall + * the Bun.file I/O and the write chain itself); the poll fallback in the + * assertions absorbs any residual scheduling skew on a loaded machine. + */ +async function settleWrites(): Promise { + await whenConfigIdle(); + await delay(20); + await whenConfigIdle(); +} + +/** Episode ids of the first feed in config.json, or null when absent. */ +async function readPersistedEpisodeIds(): Promise { + const raw = await Bun.file(configJsonPath).json().catch(() => null); + if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null; + const feeds = raw.feeds; + if (!Array.isArray(feeds) || feeds.length === 0) return null; + const first = feeds[0]; + if (!first || typeof first !== "object" || !("episodes" in first)) return null; + const episodes = first.episodes; + if (!Array.isArray(episodes)) return null; + return episodes.map((ep) => { + if (ep && typeof ep === "object" && "id" in ep) return String(ep.id); + return ""; + }); +} + +/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */ +async function pollConfigFor(ids: string[]): Promise { + const expected = [...ids].sort().join(","); + const deadline = Date.now() + 1000; + for (;;) { + const actual = (await readPersistedEpisodeIds())?.sort().join(","); + if (actual === expected) return; + if (Date.now() > deadline) { + throw new Error( + `config.json never reached expected episode ids [${ids.join(", ")}]`, + ); + } + await delay(20); + } +} + +afterAll(() => { + rmSync(configHome, { recursive: true, force: true }); +}); + +// ── Unit: episodeIsPersistable ────────────────────────────────────────────── + +test("episodeIsPersistable drops a 40-day-old episode that is not downloaded", () => { + const ep = makeEpisode({ + id: "old-plain-id", + pubDate: new Date(Date.now() - 40 * DAY), + }); + expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false); +}); + +test("episodeIsPersistable keeps a 40-day-old episode whose id is a completed download", () => { + const ep = makeEpisode({ + id: "old-downloaded-id", + pubDate: new Date(Date.now() - 40 * DAY), + }); + expect( + episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()), + ).toBe(true); +}); + +test("episodeIsPersistable keeps a 5-day-old episode", () => { + const ep = makeEpisode({ + id: "recent-id", + pubDate: new Date(Date.now() - 5 * DAY), + }); + expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true); +}); + +test("episodeIsPersistable keeps an episode with an invalid pubDate", () => { + const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) }); + expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true); +}); + +test("PERSISTED_WINDOW_DAYS is 30", () => { + expect(PERSISTED_WINDOW_DAYS).toBe(30); +}); + +// ── Save path: retention window applied with completed-download exemption ── + +test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => { + // Arrange: downloads.json lists one completed download. + mkdirSync(join(configHome, "podtui"), { recursive: true }); + await Bun.write( + downloadsJsonPath, + JSON.stringify([ + { + episodeId: "old-downloaded-id", + feedId: "feed-1", + status: "completed", + filePath: null, + downloadedAt: null, + fileSize: 0, + error: null, + audioUrl: "", + episodeTitle: "", + }, + ]), + ); + + // Act: recent episode, old plain episode, old downloaded episode. + const feed = makeFeed([ + makeEpisode({ + id: "recent-id", + pubDate: new Date(Date.now() - 5 * DAY), + }), + makeEpisode({ + id: "old-plain-id", + pubDate: new Date(Date.now() - 40 * DAY), + }), + makeEpisode({ + id: "old-downloaded-id", + pubDate: new Date(Date.now() - 40 * DAY), + }), + ]); + saveFeedsToFile([feed]); + await settleWrites(); + // The IIFE's downloads.json read can land after the first settle; poll + // briefly in case the write chain drained before that read resolved. + await pollConfigFor(["old-downloaded-id", "recent-id"]); + + // Assert: persisted episodes keep recent + downloaded, drop old-plain. + const persisted = await readPersistedEpisodeIds(); + expect(persisted).toContain("recent-id"); + expect(persisted).toContain("old-downloaded-id"); + expect(persisted).not.toContain("old-plain-id"); +}); + +// ── Load path: legacy config cleanup rewrite ─────────────────────────────── + +test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => { + // Arrange: seed config.json directly with a feed whose episodes are ALL + // older than the window; no downloads.json present. + mkdirSync(join(configHome, "podtui"), { recursive: true }); + await Bun.write( + configJsonPath, + JSON.stringify( + { + feeds: [ + { + id: "feed-1", + podcast: { + id: "feed-1", + title: "Legacy Show", + description: "", + author: "tester", + feedUrl: "https://example.com/legacy.xml", + lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(), + isSubscribed: true, + }, + episodes: [ + { + id: "old-a", + podcastId: "feed-1", + title: "Old A", + description: "", + audioUrl: "https://example.com/audio/old-a.mp3", + duration: 60, + pubDate: new Date(Date.now() - 40 * DAY).toISOString(), + }, + { + id: "old-b", + podcastId: "feed-1", + title: "Old B", + description: "", + audioUrl: "https://example.com/audio/old-b.mp3", + duration: 60, + pubDate: new Date(Date.now() - 40 * DAY).toISOString(), + }, + ], + visibility: "public", + sourceId: "source-1", + lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(), + isPinned: false, + }, + ], + }, + null, + 2, + ), + ); + + // Act. + const feeds = await loadFeedsFromFile(); + await settleWrites(); + + // Assert: returned feed has zero episodes AND config.json was rewritten + // (the cleanup save is fire-and-forget — poll for the rewritten file). + expect(feeds).toHaveLength(1); + expect(feeds[0].episodes).toHaveLength(0); + await pollConfigFor([]); + expect(await readPersistedEpisodeIds()).toEqual([]); +}); diff --git a/tests/feed-volatile-merge.test.ts b/tests/feed-volatile-merge.test.ts new file mode 100644 index 0000000..12dcdeb --- /dev/null +++ b/tests/feed-volatile-merge.test.ts @@ -0,0 +1,252 @@ +/** + * Volatile in-memory episode merge + bounded cache tests. + * + * Two behaviors from the bounded-feed-lifecycle work: + * 1. mergeEpisodes unions refreshed episodes with what's already in memory + * (the fetched copy wins on id collision), so a refresh never shrinks + * the session's visible window; the union is capped per feed at + * MAX_EPISODES_IN_MEMORY. + * 2. The per-feed parse cache is capped at MAX_EPISODES_IN_MEMORY, so + * loadMoreEpisodes can never surface more than the cap and + * hasMoreEpisodes flips false there. + * + * Unchanged-refresh detection compares the fetched window against the + * corresponding PREFIX of the merged list (sameRefreshWindow) — comparing + * full lists would bump lastUpdated on every refresh because the merged list + * legitimately holds episodes beyond the fetched window. + */ + +import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config dir at a throwaway directory BEFORE importing the stores +// (their module-level init reads it). +const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-")); +process.env.XDG_CONFIG_HOME = configHome; + +import { MAX_EPISODES_IN_MEMORY, useFeedStore } from "../src/stores/feed"; +import { mergeEpisodes } from "../src/utils/episode-merge"; +import type { Episode } from "../src/types/episode"; +import type { Podcast } from "../src/types/podcast"; + +interface ServedEpisode { + title: string; + date: string; +} + +let server: ReturnType | null = null; +let servedEpisodes: ServedEpisode[] = []; +// Bun runs test files in ONE process, so the store singleton is shared with +// the other feed test files. Track the feeds we add and remove them in +// afterAll so whichever file runs next sees a pristine store (execution +// order between files is not guaranteed). +const addedFeedIds: string[] = []; + +/** XML for the current served episode list (episode ids = feedUrl#index). */ +function feedXml(episodes: ServedEpisode[], origin: string): string { + const items = episodes + .map( + (ep, i) => ` + ${ep.title} + ${ep.date} + +`, + ) + .join("\n"); + return ` + +Volatile Show +Volatile merge test feed +${items} +`; +} + +const makePodcast = (feedUrl: string): Podcast => ({ + id: feedUrl, + title: "Volatile Show", + description: "Volatile merge test feed", + author: "tester", + feedUrl, + lastUpdated: new Date(), + isSubscribed: true, +}); + +const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({ + id, + podcastId: "pod", + title, + description: "", + audioUrl: `https://example.com/${id}.mp3`, + duration: 100, + pubDate, +}); + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith(".xml")) { + return new Response(feedXml(servedEpisodes, url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterAll(() => { + vi.useRealTimers(); + // Leave the shared singleton as we found it (see addedFeedIds note). + const store = useFeedStore(); + for (const id of addedFeedIds) store.removeFeed(id); + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); +}); + +// ── mergeEpisodes unit tests ───────────────────────────────────────────── + +test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => { + const existing = [ + makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")), + makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")), + ]; + const fetched = [ + makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")), + ]; + + const merged = mergeEpisodes(existing, fetched, 10); + + expect(merged).toHaveLength(2); + expect(merged.find((e) => e.id === "a")!.title).toBe("New Title"); +}); + +test("mergeEpisodes unions disjoint lists sorted newest-first", () => { + const existing = [ + makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")), + ]; + const fetched = [ + makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")), + makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")), + ]; + + const merged = mergeEpisodes(existing, fetched, 10); + + expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]); +}); + +test("mergeEpisodes drops the oldest episodes past the cap", () => { + const existing = [ + makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")), + ]; + const fetched = [ + makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")), + makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")), + ]; + + const merged = mergeEpisodes(existing, fetched, 2); + + expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]); +}); + +test("mergeEpisodes never mutates its inputs", () => { + const existing = [ + makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")), + makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")), + ]; + const fetched = [ + makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")), + makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")), + ]; + const existingIds = existing.map((e) => e.id); + const existingTitles = existing.map((e) => e.title); + const fetchedIds = fetched.map((e) => e.id); + const fetchedTitles = fetched.map((e) => e.title); + + mergeEpisodes(existing, fetched, 10); + + expect(existing.map((e) => e.id)).toEqual(existingIds); + expect(existing.map((e) => e.title)).toEqual(existingTitles); + expect(fetched.map((e) => e.id)).toEqual(fetchedIds); + expect(fetched.map((e) => e.title)).toEqual(fetchedTitles); +}); + +// ── store integration ──────────────────────────────────────────────────── + +test("refresh merges new episodes without removing the volatile window", async () => { + const store = useFeedStore(); + servedEpisodes = [ + { title: "Ep 3", date: "2026-08-03T00:00:00Z" }, + { title: "Ep 2", date: "2026-08-02T00:00:00Z" }, + { title: "Ep 1", date: "2026-08-01T00:00:00Z" }, + ]; + const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + expect(store.getFeed(id)!.episodes.length).toBe(3); + const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime(); + + // The feed now serves the same 3 episodes plus 2 newer ones (new ids at + // item indices 3 and 4). + servedEpisodes = [ + { title: "Ep 3", date: "2026-08-03T00:00:00Z" }, + { title: "Ep 2", date: "2026-08-02T00:00:00Z" }, + { title: "Ep 1", date: "2026-08-01T00:00:00Z" }, + { title: "Ep 5", date: "2026-08-05T00:00:00Z" }, + { title: "Ep 4", date: "2026-08-04T00:00:00Z" }, + ]; + + vi.advanceTimersByTime(60_000); + await store.refreshFeed(id); + + const afterFirst = store.getFeed(id)!; + expect(afterFirst.episodes.length).toBe(5); + expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated); + + // Identical second refresh: no lastUpdated bump, object identity kept. + vi.advanceTimersByTime(60_000); + await store.refreshFeed(id); + + const afterSecond = store.getFeed(id)!; + expect(afterSecond).toBe(afterFirst); + expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime()); +}); + +test("cached episodes are capped at MAX_EPISODES_IN_MEMORY", async () => { + const store = useFeedStore(); + servedEpisodes = Array.from({ length: 600 }, (_, i) => ({ + title: `Ep ${600 - i}`, + date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/huge.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + // Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with 480 more cached. + expect(store.getFeed(id)!.episodes.length).toBe(20); + + // Load in MAX_EPISODES_REFRESH chunks until the cache is exhausted. + let maxLoaded = 0; + let iterations = 0; + while (store.hasMoreEpisodes(id) && iterations < 20) { + await store.loadMoreEpisodes(id); + maxLoaded = Math.max(maxLoaded, store.getFeed(id)!.episodes.length); + iterations++; + } + + expect(iterations).toBeLessThan(20); + expect(store.hasMoreEpisodes(id)).toBe(false); + expect(store.getFeed(id)!.episodes.length).toBe(MAX_EPISODES_IN_MEMORY); + expect(maxLoaded).toBeLessThanOrEqual(MAX_EPISODES_IN_MEMORY); +}); diff --git a/tests/global-activity-indicator.test.tsx b/tests/global-activity-indicator.test.tsx new file mode 100644 index 0000000..1f87ab0 --- /dev/null +++ b/tests/global-activity-indicator.test.tsx @@ -0,0 +1,204 @@ +/** + * Global activity indicator — the shared leak-proof activity store + * (begin/end counter + track helper) and the global top-right overlay that + * surfaces feed refresh, fetch-more, subscribe fetch, search, and download + * activity. The download transfer is left in flight on purpose (the test + * server delays its response) so the "Downloading" state is observable in + * the captured frame. + */ + +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config/data dirs at throwaway directories BEFORE importing the +// stores (their module-level init reads them) and silence the audio backend. +const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-")); +process.env.XDG_CONFIG_HOME = configHome; +const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-")); +process.env.XDG_DATA_HOME = dataHome; +process.env.PODTUI_AUDIO_BACKEND = "none"; + +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator"; +import { useActivityStore } from "../src/stores/activity"; +import { useDownloadStore } from "../src/stores/download"; +import type { Episode } from "../src/types/episode"; + +// The LoadingIndicator glyph cycle. +const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/; + +type Frame = { cols: number; lines: { spans: { text: string }[] }[] }; +const frameLines = (f: Frame): string[] => + f.lines.map((l) => l.spans.map((s) => s.text).join("")); + +function sleep(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + +/** Render the indicator in isolation under the dark theme. The ThemeProvider + * gates children on async init (capabilities + palette detection, up to + * ~1.5s under tmux), so settle frames until the indicator is mounted. */ +async function renderIndicator() { + const setup = await testRender( + () => ( + + + + ), + { width: 60, height: 10, useThread: false }, + ); + await setup.renderOnce(); + for (let i = 0; i < 40; i++) { + await setup.renderOnce(); + await sleep(50); + } + return setup; +} + +const frameText = (setup: { captureSpans: () => unknown }): string => + frameLines(setup.captureSpans() as unknown as Frame).join("\n"); + +let server: ReturnType | null = null; +let audioUrl = ""; +/** Response delay (ms) for the next audio request — keeps the transfer in + * flight while the "Downloading" state is asserted. */ +let audioDelayMs = 0; +/** Episode ids this file started downloads for (shared singleton cleanup). */ +const downloadedEpisodeIds: string[] = []; + +const makeEpisode = (id: string, title: string): Episode => ({ + id, + podcastId: "pod", + title, + description: "", + audioUrl, + duration: 0, + pubDate: new Date("2026-08-01T00:00:00Z"), +}); + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch() { + const { promise, resolve } = Promise.withResolvers(); + setTimeout( + () => + resolve( + new Response("audio bytes", { + headers: { "Content-Type": "audio/mpeg" }, + }), + ), + audioDelayMs, + ); + return promise; + }, + }); + audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`; +}); + +afterAll(async () => { + const dl = useDownloadStore(); + for (const id of downloadedEpisodeIds) { + dl.cancelDownload(id); + await dl.removeDownload(id); + } + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); + rmSync(dataHome, { recursive: true, force: true }); +}); + +test("beginActivity/end pairs compose: ending one keeps the other active", () => { + const activity = useActivityStore(); + expect(activity.isActive()).toBe(false); + expect(activity.labels()).toEqual([]); + + const endFirst = activity.beginActivity("Refreshing"); + const endSecond = activity.beginActivity("Refreshing"); + expect(activity.isActive()).toBe(true); + expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]); + + endFirst(); + expect(activity.isActive()).toBe(true); + expect(activity.labels()).toEqual(["Refreshing"]); + + endSecond(); + expect(activity.isActive()).toBe(false); + expect(activity.labels()).toEqual([]); +}); + +test("track re-throws rejection and returns isActive() to its prior value", async () => { + const activity = useActivityStore(); + const prior = activity.isActive(); + await expect( + activity.track(Promise.reject(new Error("boom")), "Refreshing"), + ).rejects.toThrow("boom"); + expect(activity.isActive()).toBe(prior); + expect(activity.labels()).toEqual([]); +}); + +test("idle: renders nothing, no spinner, no label", async () => { + const setup = await renderIndicator(); + const text = frameText(setup); + expect(text).not.toMatch(SPINNER_RE); + expect(text).not.toContain("…"); + expect(text).not.toContain("Downloading"); + expect(text.trim()).toBe(""); + setup.renderer.destroy(); +}); + +test("tracked activity: spinner + label appear while active, vanish on end", async () => { + const activity = useActivityStore(); + const setup = await renderIndicator(); + expect(frameText(setup)).not.toMatch(SPINNER_RE); + + const end = activity.beginActivity("Refreshing"); + await setup.renderOnce(); + const active = frameText(setup); + expect(active).toMatch(SPINNER_RE); + expect(active).toContain("Refreshing…"); + + end(); + await setup.renderOnce(); + const done = frameText(setup); + expect(done).not.toMatch(SPINNER_RE); + expect(done).not.toContain("Refreshing…"); + expect(done.trim()).toBe(""); + setup.renderer.destroy(); +}); + +test("active download: 'Downloading' label appears and disappears", async () => { + const dl = useDownloadStore(); + const setup = await renderIndicator(); + + // Keep the transfer in flight while asserting; the response only lands + // after audioDelayMs, so the download stays DOWNLOADING across renders. + audioDelayMs = 400; + const episode = makeEpisode("activity-dl-ep", "DL Ep"); + downloadedEpisodeIds.push(episode.id); + dl.startDownload(episode, "activity-test-feed"); + + await setup.renderOnce(); + const during = frameText(setup); + expect(during).toContain("Downloading 1"); + + // Cancel: the abort settles the fetch and activeCount returns to 0. + dl.cancelDownload(episode.id); + for (let i = 0; i < 40; i++) { + if (dl.getActiveCount() + dl.getQueue().length === 0) break; + await sleep(25); + } + await dl.removeDownload(episode.id); + await setup.renderOnce(); + const after = frameText(setup); + expect(dl.getActiveCount() + dl.getQueue().length).toBe(0); + expect(after).not.toMatch(SPINNER_RE); + expect(after).not.toContain("Downloading"); + + audioDelayMs = 0; + setup.renderer.destroy(); +}); diff --git a/tests/yazi-pane-row.test.tsx b/tests/yazi-pane-row.test.tsx index de13bd9..d833c12 100644 --- a/tests/yazi-pane-row.test.tsx +++ b/tests/yazi-pane-row.test.tsx @@ -66,6 +66,7 @@ type TestPaneProps = { current?: (() => unknown) | unknown; preview?: unknown; focused?: unknown; + currentBorder?: unknown; width?: number; height?: number; }; @@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{ preview={props.preview as any} currentLabel="List" focused={props.focused as any} + currentBorder={props.currentBorder as any} /> ), @@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => { expect(borderColumns(spans)).toEqual([20, 69]); expect(frameText(spans)).not.toMatch(boxGlyphs); }); + + test("currentBorder=['left'] removes the right edge (left only)", async () => { + const { spans, destroy } = await renderPaneRow({ + parent: null, + current: () => ITEM, + preview: null, + currentBorder: ["left"], + }); + cleanups.push(destroy); + // Only the left border glyph at column 20 — no right edge at 69. + expect(borderColumns(spans)).toEqual([20]); + expect(frameText(spans)).not.toMatch(boxGlyphs); + }); });