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.
Episode search results gain d (download), D (delete), x (unsubscribe)
and enter (play for subscribed shows); downloads of unsubscribed shows
are recorded with the show's metadata under a deterministic synthetic
feed id and listed under an "Unsubscribed Show Downloads" section in
My Shows and the settings Download Manager. Classified at render time
by feed id or feed URL, so subscribing re-classifies the downloads and
unsubscribing purges them.
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.
History used localStorage, which never exists in the Bun TUI, so nothing
survived a restart. Store the 10 most recent queries in config-dir
search-history.json (same fire-and-forget file pattern as audio-nav.json),
loaded asynchronously at store init. Dedupe case-insensitively, cap at 10.
Search now covers individual episodes, not just shows: the iTunes
Search API (entity=podcastEpisode) matches episode titles and show
notes, so a guest or topic finds the episodes they appear in across
shows. Enter on an episode result subscribes to the parent show.
- types: SearchResult becomes a kind-discriminated union
(podcast | episode); EpisodeSearchResult carries the parent show so
existing consumers compile unchanged
- source-searcher: searchEpisodesByType (RSS/CUSTOM return []),
buildItunesEpisodeUrl, cleanDescription (HTML -> text),
mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses
mapItunesResult so delisted shows keep a directoryUrl)
- search: searchEpisodes with an 'episode' cache/dedupe namespace so
shows and episodes for the same query never mix
- stores/search: scope signal (podcast | episode) persisted to
podtui_search_scope; search() branches on scope
- SearchPage: Shows/Episodes pills row with 'tab to toggle', scope-
aware placeholder/empty state/result rows (episode row = title +
Show · date) and preview; toggling re-runs the current query
- keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the
runtime DEFAULT_KEYBINDS merge so the binding exists for users with
a pre-existing config file); while the input is focused the Shell
router never sees Tab, so the input handles it via onKeyDown +
preventDefault (no double-toggle: the router path only fires when
the input is defocused)
- Shell help overlay documents [tab] shows/episodes
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.
Adds a Selection Marker toggle under Settings → Preferences that
controls the ❯ cursor glyph on the focused row of every list.
Default off; rows keep a leading space for alignment either way.
Every list pane (tab strip, feed, my shows, discover, search,
settings, whitelist editor) reads the glyph through the shared
useSelectionMarker hook.
Also aligns the lead column across panes: page list rows drop
their extra box paddingLeft so labels start at the same column
as the tab strip (2 cells without nerd icons, 4 with), keeping
episode sub-rows aligned to the title.
Tabs, Discover categories (replacing unused placeholder glyphs), Settings
sections, and the Feed 'Fetch More' row get Nerd Font glyphs (Font Awesome PUA
codepoints). When the terminal font isn't Nerd Font capable the glyphs render
nothing at all — no tofu, no layout gaps — via supportsNerdFonts() (env
allowlist + PODTUI_NERD_FONTS=1/0 override). Documented in README (Configuration
-> Fonts).
- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
cover-art-auto=exact picks it up for artwork; delete it with the download
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.