Add episodeCacheMode/count/days preferences (default: date, 60 days).
Apply the bound when reading instead of writing, so a preference change
takes effect without a refetch; the full parse cache stays intact so
fetch-more can page beyond the bound. Thread the window through
load/saveFeedsToFile and update tests and task docs.
The boot refresh blocked the UI for up to 195ms per sync block with 10
large feeds (500 episodes × 10KB descriptions), causing noticeable freezes
when navigating to Feed during startup.
Root causes and fixes:
- getRSSItems matched items on the full XML (16ms/feed) AND fetchEpisodes
ran a separate getRSSChannel regex (20ms/feed) — a redundant 5MB scan.
Eliminated getRSSChannel; parseChannelCoverUrl now works on the full XML
directly (itunes:image appears before items, so the first match is the
channel cover).
- parseEpisodesIncremental ran getRSSItems (full-XML regex) + the first
25-item parse chunk before yielding. Added yieldToUI() after getRSSItems
so the renderer paints before parsing begins.
- Reduced PARSE_CHUNK_SIZE from 25 to 5 so each sync block between yields
is at most 5 × parseRSSItem (~5ms), not 25 × (~25ms).
- Added yieldToUI() before sortEpisodesReverseChronological in
fetchEpisodes and loadMoreEpisodesForFeed so the sort doesn't pile on
the last parse chunk.
- Added yieldToUI() after response.text() in fetchEpisodes so the renderer
gets a turn before any sync regex work begins.
Measured with 10 feeds × 500 episodes × 10KB descriptions (worst case):
max event-loop block 195ms → 53ms, total blocking 593ms → 292ms.
The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
(getDownloadedFilePath) over the stream URL — downloaded episodes now
play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
loadMoreEpisodesForFeed's hot-cache path ran fully synchronously (no
await between getFeed and setFeeds), so the isLoadingMore spinner never
painted and keyboard input froze through every feed in a loadMoreAllFeeds
batch. Add await yieldToUI() before the setFeeds so the renderer gets a
macrotask turn to paint and process input between feeds.
Drop the "Loading…/Refreshing…/Downloading N" label text from the global
activity indicator — render the braille spinner only, per request.
Persisted feeds keep only episodes from the last 30 days (plus completed
downloads); older episodes live in volatile memory and survive refreshes
via union merge, with per-feed in-memory caches capped at 500. Refresh
batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no
Promise.all barrier), config.json writes are trailing-edge debounced
(250ms, immediate flushPendingSave for unsubscribes), and cold
fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store
powers a global top-right indicator covering refresh, fetch-more,
subscribe, search, and downloads.
Also includes the in-flight incremental RSS parsing (chunked with
event-loop yields) and refresh spinner work this tree already carried.
Reload the episode that was loaded in the player when the previous run
ended (persisted on play/load and synchronously at exit) into the Player
tab paused at its saved position — never autostarted. Episodes at or
above 98% completion are skipped, as are empty-player and unsubscribed
episodes.
- useAudio gains load(episode) (sets currentEpisode/position/Now Playing
without starting the backend) and restoreLastSession(), triggered once
at boot and serialized through a chain so a late-finishing boot restore
can't clobber later state.
- togglePlayback branches on a startedPlayback flag: a restored episode
starts the backend from the saved position; a paused one resumes.
- stop() clears the marker; the exit teardown writes it synchronously
(process.exit bypasses async writes).
- feed/progress stores expose whenReady() so restore waits for the async
boot loads; feed store gains findEpisode().
- app-persistence serializes last-player marker writes and exposes
waitForLastPlayerWrite() for deterministic tests.
- tests/restore-session.test.ts: real modules + local RSS feed server;
the real useAudio is imported via a ?restore-test query suffix to
bypass the suite's mock.module leak across shared bun workers.
Self-rescheduling refresh timer (default 30 min, configurable via a
Preferences item, re-read on every tick, skips in-flight refreshes).
fetchEpisodes returns null on network failure/timeout so a failed
refresh can never wipe a feed's episodes (addFeed/refreshFeed/
refreshAllFeeds all treat null as unchanged); feeds still refresh on
launch.
Podcast Index (api.podcastindex.org) ships as a disabled, key-less source
and is only consulted as a fallback when primary search results are fewer
than 3 — never on the hot path, never when disabled or credential-less.
A failed fallback leaves primary results intact.
Credentials are user-supplied: enabling the source pops a dialog that
asks for the free key+secret, prefilled masked (first 3 chars + "...")
when already stored; toggling off never clears them. Secrets prefer the
macOS keychain (security CLI, encrypted at rest) with a plaintext
config.json fallback when the keychain is unavailable; sources carry only
a hasCredentials/credentialStorage marker, and legacy plaintext keys in
existing configs are migrated on load.
Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and
Authorization = sha1(key + secret + date). Dead feeds are filtered, feed
URLs are used directly, and episode-scope search is a no-op (no endpoint).
Shows that left Apple Podcasts (e.g. Daily Wire's in 2021) come back from
the iTunes Search API as metadata-only stub records with feedUrl null.
mapItunesResult dropped them, so The Ben Shapiro Show — the #1 hit for
'ben shapiro' — never appeared in search while sibling shows did.
- Keep feedUrl-less results (feedUrl "" + directoryUrl pointing at the
Apple page) so delisted shows stay findable.
- Resolve the real feed from the Apple page at subscribe time
(itunes-feed-resolver: anchor on the collection's adamId, forward-scan
for the embedded feedUrl; Apple serves page variants where the
showOffer block sits thousands of chars after the adamId).
- addFeed refuses feedless stubs whose feed can't be resolved instead of
adding a broken feed; SearchPage surfaces the failure via toast.
- Tests: stub mapping, extractor variants, and an end-to-end subscribe
over a local HTTP server.
Two fixes to refresh order stability (My Shows / Feed sort by
lastUpdated):
- A refresh that fetches identical episodes no longer bumps
lastUpdated (id-set comparison via sameEpisodes), so unchanged
feeds keep their position instead of reordering every cycle.
- refreshAllFeeds now fetches in parallel and applies ONE atomic
update instead of a per-feed setFeeds, which re-sorted the list
once per completion and made order flap until the batch finished.
Adds feed-refresh regression tests with mocked clock.
Loading indicators: the braille spinner now carries a contextual label
(Refreshing…, Fetching…, Loading more…, Discovering…, Searching…) and is
shown in every loading state that previously rendered nothing — Discover
results, Search results fallback, and the empty Feed list.
Feed pagination: a focusable "[Fetch More]" row at the bottom of the flat
feed list advances every feed's loaded window by 50 episodes via the new
loadMoreAllFeeds/hasMoreAcrossAll store API. Behavior is a setting
(Fetch More: manual|auto, default manual) persisted in config.json; auto
fetches when focus reaches the bottom row. The button row is excluded
from episode focus so no episode is double-highlighted while it is active.