7 Commits

Author SHA1 Message Date
b2e9e5c16c bump VERSION to 0.4.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m24s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 09:57:58 -04:00
41c0002090 test(search): query input focus follows real focus
Regression coverage for the search input's focus flag: it must track
the renderable's REAL focus (useInputFocusNav FOCUSED/BLURRED), not a
flag that outlives it. Clicking off the input drops inputFocused and
keyboard control resumes; s re-enters typing; Esc defocuses; clicking
the input refocuses.

Documents the observed full-suite CPU-contention flake in the header:
both tests occasionally time out in openSearch under suite load while
passing reliably in isolation.
2026-08-11 07:26:35 -04:00
c0252fc9b8 fix: playback controls block now wraps 2026-08-11 07:24:59 -04:00
0c3506beb5 feat(search): episode search scope with tab toggle
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
2026-08-11 00:50:13 -04:00
ef9fc13aaa feat(settings): add Podcast Index fallback source with credential storage
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).
2026-08-11 00:37:56 -04:00
0b0637b9dc fix(search): surface iTunes stubs and resolve feeds for delisted shows
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.
2026-08-10 22:38:03 -04:00
e73e608b9f feat(myshows): add Fetch More row to per-show episode lists
Mirrors the Feed tab's '[Fetch More]' row inside a drilled show's episode
list (My Shows depth 1): shows only while the show's cache holds episodes
beyond its loaded window, and advances just that show's window by 50 on
Enter (or automatically at the bottom in auto mode). Same fetchMoreMode
preference drives both behaviors. Adds a store-contract test for the
per-feed pagination path.
2026-08-10 22:37:51 -04:00
28 changed files with 2503 additions and 167 deletions

View File

@@ -205,7 +205,7 @@ a suggestion in/out), and `w` in My Shows adds/removes the focused show.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed "Fetch More" row). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
## Troubleshooting

View File

@@ -447,6 +447,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
["enter", "open"],
["r", "refresh"],
["s", "search"],
[p("search-scope-toggle"), "shows/episodes"],
["f", "filter"],
[",", "sort"],
[".", "hidden"],

View File

@@ -59,8 +59,11 @@
"help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"],
"filter": ["f"],
"search": ["s"],
// tab toggles the Search page between show and episode scope (re-runs the
// current query when viewing results)
"search-scope-toggle": ["tab"],
"filter": ["f"],
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],

View File

@@ -63,6 +63,7 @@ export type KeybindActionName =
| "quit"
| "help"
| "search"
| "search-scope-toggle"
| "filter"
| "sort"
| "toggle-hidden"

View File

@@ -1,7 +1,7 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.1";
const VERSION = "0.4.0";
interface CliArgs {
version: boolean;

View File

@@ -6,12 +6,15 @@
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
* as the Feed tab) that loads the next batch of episodes for that show.
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
@@ -28,6 +31,7 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
@@ -40,6 +44,8 @@ import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const app = useAppStore();
@@ -71,17 +77,40 @@ export function MyShowsPage() {
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
const focusedOnMore = () =>
showFetchMore() && focusedRow() === episodes().length;
// -1 while the Fetch More row is focused so no episode row renders the
// cursor/highlight (the button is the focused row, not the last episode).
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
const focusedEpisode = () => episodes()[focusedEpIdx()];
focusedOnMore()
? -1
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
const curLen = () => (depth() === 0 ? shows().length : rowCount());
const ensureFocus = () => {
if (shows().length > 0 && focus(0) >= shows().length)
nav.setDepthFocus(shows().length - 1, 0);
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 1);
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(rowCount() - 1, 1);
};
onMount(ensureFocus);
@@ -92,6 +121,17 @@ export function MyShowsPage() {
});
});
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
@@ -143,6 +183,10 @@ export function MyShowsPage() {
return;
}
if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
@@ -405,9 +449,38 @@ export function MyShowsPage() {
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Loading more…" />
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
</Show>
</Show>
@@ -456,15 +529,33 @@ export function MyShowsPage() {
)}
</Show>
) : (
// depth ≥1 preview: hovered episode
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
}
>
</Show>
<Show when={!focusedOnMore()}>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
@@ -508,8 +599,10 @@ export function MyShowsPage() {
</text>
</box>
)}
</Show>
</Show>
);
</>
);
return (
<PaneRow

View File

@@ -14,70 +14,81 @@ type PlaybackControlsProps = {
onSpeedChange: (value: number) => void;
};
const BACKEND_LABELS: Record<BackendName, string> = {
mpv: "mpv",
none: "none",
};
export function PlaybackControls(props: PlaybackControlsProps) {
const { theme } = useTheme();
return (
<box
flexDirection="row"
flexWrap="wrap"
gap={1}
alignItems="center"
justifyContent="center"
border
padding={1}
borderColor={theme.border}
>
<box
border
padding={0}
onMouseDown={props.onPrev}
borderColor={theme.border}
>
<text fg={theme.primary}>[Prev]</text>
{/* transport buttons — wrap as a unit, centered on their own line */}
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
<box
border
padding={0}
onMouseDown={props.onPrev}
borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">[Prev]</text>
</box>
<box
border
padding={0}
onMouseDown={props.onToggle}
borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box
border
padding={0}
onMouseDown={props.onNext}
borderColor={theme.border}
>
<text fg={theme.primary} wrapMode="none">[Next]</text>
</box>
</box>
{/* status group — always follows the buttons; wrap point is here */}
<box
border
padding={0}
onMouseDown={props.onToggle}
borderColor={theme.border}
flexDirection="row"
gap={1}
alignItems="center"
marginLeft={2}
flexShrink={0}
>
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box
border
padding={0}
onMouseDown={props.onNext}
borderColor={theme.border}
>
<text fg={theme.primary}>[Next]</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Vol</text>
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
<text fg={theme.textMuted}></text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Speed</text>
<text fg={theme.text}>{props.speed}x</text>
<text fg={theme.textMuted}>s</text>
</box>
{props.backendName && props.backendName !== "none" && (
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>via</text>
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
<text fg={theme.textMuted}>Speed</text>
<text fg={theme.text}>{props.speed}x</text>
<text fg={theme.textMuted}>s</text>
</box>
)}
{props.backendName === "none" && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio player found</text>
</box>
)}
{props.hasAudioUrl === false && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio URL</text>
</box>
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
{(props.backendName === "none" || props.hasAudioUrl === false) && (
<box
flexDirection="row"
gap={1}
alignItems="center"
flexShrink={0}
>
{props.backendName === "none" && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio player found</text>
</box>
)}
{props.hasAudioUrl === false && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio URL</text>
</box>
)}
</box>
)}
</box>

View File

@@ -8,6 +8,10 @@
* query (muted, read-only); preview shows the detail of
* the focused result.
*
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
* (clickable pills on the query depth too); toggling while viewing results
* re-runs the current query in the new scope.
*
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
@@ -26,6 +30,7 @@ import {
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { useToast } from "@/ui/toast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -37,18 +42,20 @@ import {
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source";
import type { SearchResult, SearchScope } from "@/types/source";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
const feedStore = useFeedStore();
const toast = useToast();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
@@ -67,22 +74,29 @@ function SearchPage() {
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// Typing is the default only on the query depth (0); the results depth
// (1) is always list-navigation. Drive `inputFocused` straight off
// `depth()` rather than seeding it `true` on mount and patching on change:
// the depth stack persists across tab switches, so re-mounting this page
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
// j/k (yielding to a non-existent input) and only the scrollbox's native
// scroll responds.
// The input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The depth STACK signal is also written by focus moves (setDepthFocus),
// so gate the sync on the depth VALUE via a memo: the effect must re-run
// only on an actual depth transition. Without the memo every j/k at the
// query depth re-focuses the input (undoing Escape), which keeps the
// recents list unreachable by keyboard.
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();
const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => {
nav.setInputFocused(isQueryDepth());
@@ -111,7 +125,10 @@ function SearchPage() {
// Register a visual-mode resolver for the results list (depth 1).
onMount(() => {
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
nav.registerResolver(key, (i) => {
const r = results()[i];
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
});
});
// ── helpers ─────────────────────────────────────────────────────────────────
@@ -136,10 +153,36 @@ function SearchPage() {
runSearch(query);
};
const handleSubscribe = (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result subscribed
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
searchStore.markSubscribed(result.podcast.id);
/** Set show/episode scope; when viewing results, re-run the current query
* so the list switches immediately (the toggle is otherwise invisible on
* a list of results). */
const applyScope = (next: SearchScope) => {
searchStore.setScope(next);
if (depth() >= 1) {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
}
};
const toggleScope = () =>
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
const handleSubscribe = async (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result
// subscribed. addFeed returns null when a feedless directory stub
// (delisted show) can't be resolved — tell the user why.
const feed = await feedStore
.addFeed(result.podcast, result.sourceId)
.catch(() => null);
if (!feed && !result.podcast.feedUrl) {
toast.show({
title: "Can't subscribe",
message:
"No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
variant: "error",
});
return;
}
if (feed) searchStore.markSubscribed(result.podcast.id);
};
// ── nav.action handler ──────────────────────────────────────────────────────
@@ -156,13 +199,17 @@ function SearchPage() {
"toggle-select": () => {
if (depth() === 1) {
const r = focusedResult();
if (r) nav.toggleSelected(r.podcast.id);
if (r)
nav.toggleSelected(
r.kind === "episode" ? r.episode.id : r.podcast.id,
);
}
},
search: () => {
// `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true);
},
"search-scope-toggle": () => toggleScope(),
refresh: () => {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
@@ -234,6 +281,9 @@ function SearchPage() {
<text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={theme.textSecondary}>
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
</text>
<text fg={muted()}>h: back to query</text>
</box>
</Show>
@@ -249,10 +299,33 @@ function SearchPage() {
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
<input
ref={focusNavRef}
value={inputValue()}
onInput={setInputValue}
onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..."
onMouseDown={(evt) => {
// Clicking the input must focus it (typing mode).
// preventDefault stops opentui's click auto-focus from
// grabbing the pane scrollbox instead; setting the flag
// drives the `focused` prop → renderable focus → the
// useInputFocusNav FOCUSED handler.
evt.preventDefault();
nav.setInputFocused(true);
}}
onKeyDown={(evt) => {
// While the input owns keys the Shell router never sees
// Tab, so the scope toggle must be handled here (the
// pills and the tab keybind cover the defocused cases).
if (evt.name === "tab") {
evt.preventDefault();
toggleScope();
}
}}
placeholder={
searchStore.scope() === "episode"
? "Enter episode, guest, topic..."
: "Enter podcast name..."
}
focused={inputActive()}
width={28}
textColor={theme.text}
@@ -260,6 +333,44 @@ function SearchPage() {
cursorColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textSecondary}>Scope:</text>
<box
backgroundColor={
searchStore.scope() === "podcast" ? theme.primary : undefined
}
onMouseDown={() => applyScope("podcast")}
>
<text
fg={
searchStore.scope() === "podcast"
? theme.surface
: muted()
}
>
{" "}
Shows{" "}
</text>
</box>
<box
backgroundColor={
searchStore.scope() === "episode" ? theme.primary : undefined
}
onMouseDown={() => applyScope("episode")}
>
<text
fg={
searchStore.scope() === "episode"
? theme.surface
: muted()
}
>
{" "}
Episodes{" "}
</text>
</box>
<text fg={muted()}>tab to toggle</text>
</box>
<Show when={searchStore.isSearching()}>
<LoadingIndicator label="Searching…" />
</Show>
@@ -332,7 +443,7 @@ function SearchPage() {
<text fg={muted()}>
{inputActive()
? "Enter to search · Esc to defocus"
: "j/k recents · s to type · h back"}
: "j/k recents · s to type · tab scope · h back"}
</text>
</box>
</Show>
@@ -348,7 +459,9 @@ function SearchPage() {
<text fg={muted()}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
: searchStore.scope() === "episode"
? "Enter a search term to find episodes"
: "Enter a search term to find podcasts"}
</text>
}
>
@@ -378,7 +491,9 @@ function SearchPage() {
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title}
{result.kind === "episode"
? result.episode.title
: result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
@@ -388,14 +503,24 @@ function SearchPage() {
</text>
</Show>
</box>
<Show when={result.podcast.author}>
{result.kind === "episode" ? (
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
{result.podcast.title} ·{" "}
{formatDate(result.episode.pubDate)}
</text>
</Show>
) : (
<Show when={result.podcast.author}>
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
)}
</box>
);
}}
@@ -413,6 +538,10 @@ function SearchPage() {
<strong>Search</strong>
</text>
<text fg={muted()}>Type a query, press Enter to search.</text>
<text fg={muted()}>
Tab toggles Shows Episodes (episode search finds guests
and topics).
</text>
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
<box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
@@ -429,52 +558,102 @@ function SearchPage() {
</box>
}
>
{(result) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400)}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
{(result) => {
const r = result();
if (r.kind === "episode") {
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{r.episode.title}</strong>
</text>
<text fg={theme.textSecondary}>{r.podcast.title}</text>
<Show when={r.podcast.author}>
<text fg={muted()}>by {r.podcast.author}</text>
</Show>
<Show when={r.episode.description}>
<text fg={theme.textSecondary}>
{r.episode.description!.slice(0, 400)}
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>
enter: subscribe to show · h: back to query
</text>
</box>
</Show>
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
<text fg={muted()}>
Updated: {formatDate(result().podcast.lastUpdated)}
</text>
<Show when={result().sourceName}>
<text fg={muted()}>Source: {result().sourceName}</text>
</Show>
<box height={1} />
<Show when={!result().podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
</box>
)}
);
}
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{r.podcast.title}</strong>
</text>
<Show when={r.podcast.author}>
<text fg={muted()}>by {r.podcast.author}</text>
</Show>
<Show when={r.podcast.description}>
<text fg={theme.textSecondary}>
{r.podcast.description!.slice(0, 400)}
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text>
</Show>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<text fg={muted()}>
Feed:{" "}
{r.podcast.feedUrl ||
"not listed by source — resolves on subscribe"}
</text>
<text fg={muted()}>
Updated: {formatDate(r.podcast.lastUpdated)}
</text>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
</box>
);
}}
</Show>
);
const currentLabel = () =>
depth() === 0
? `Search · ${recents().length} recent`
: `Results · ${results().length}`;
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
return (
<PaneRow

View File

@@ -211,7 +211,7 @@ export function usePreferencesItems(): SettingItem[] {
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");

View File

@@ -11,16 +11,24 @@
* right-pane key conflicts).
*/
import { createSignal, For, Show } from "solid-js";
import { createSignal, For, Show, onMount } from "solid-js";
import { Renderable } from "@opentui/core";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { useDialog } from "@/ui/dialog";
import { useToast } from "@/ui/toast";
import {
resolveSourceCredentials,
savePodcastIndexCredentials,
} from "@/utils/source-credentials";
import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types";
export function useSourceItems(): SettingItem[] {
const feedStore = useFeedStore();
const dialog = useDialog();
const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API
@@ -48,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
kind: "toggle",
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
help: () =>
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => feedStore.toggleSource(s.id),
s.id === "podcastindex"
? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.`
: `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => {
// Enabling Podcast Index requires credentials: ask first
// (prefilled with the stored key, masked) instead of flipping
// the source into a key-less "on" state. Disabling never
// clears the stored credentials.
if (s.id === "podcastindex" && !s.enabled) {
dialog.push(() => <PodcastIndexCredentialsDialog />);
return;
}
feedStore.toggleSource(s.id);
},
});
}
@@ -151,3 +171,152 @@ function AddSourceForm() {
</box>
);
}
/** Mask a stored credential for prefill: first 3 chars then "...". */
const maskCredential = (value: string): string => `${value.slice(0, 3)}...`;
/** Credentials popup shown when enabling the Podcast Index source. Prefilled
* (masked) with stored credentials so re-enabling just needs Enter; leaving
* a masked field untouched keeps the stored value. Credentials are saved to
* the macOS keychain (encrypted at rest) with a plaintext config.json
* fallback when the keychain is unavailable. */
function PodcastIndexCredentialsDialog() {
const feedStore = useFeedStore();
const { theme } = useTheme();
const dialog = useDialog();
const toast = useToast();
const source = feedStore.sources().find((s) => s.id === "podcastindex");
const [key, setKey] = createSignal("");
const [secret, setSecret] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [saving, setSaving] = createSignal(false);
// Yield navigation keybinds to the Shell router while an input is focused.
const keyRef = useInputFocusNav();
const secretRef = useInputFocusNav();
let keyEl: Renderable | null | undefined;
let secretEl: Renderable | null | undefined;
onMount(() => {
// Prefill stored credentials (masked) when re-enabling after a
// disable — toggling off never clears them. Masked either way, so a
// plaintext-stored key never appears in full in the UI.
if (source) {
resolveSourceCredentials(source)
.then((stored) => {
if (stored?.apiKey) setKey(maskCredential(stored.apiKey));
if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret));
})
.catch(() => {});
}
setTimeout(() => keyEl?.focus(), 1);
});
const save = async () => {
if (saving()) return;
const stored = source
? await resolveSourceCredentials(source).catch(() => null)
: null;
const keyValue = key().trim();
const secretValue = secret().trim();
// A field still showing its masked prefill means "keep what's stored".
const apiKey =
stored?.apiKey && keyValue === maskCredential(stored.apiKey)
? stored.apiKey
: keyValue;
const apiSecret =
stored?.apiSecret && secretValue === maskCredential(stored.apiSecret)
? stored.apiSecret
: secretValue;
if (!apiKey || !apiSecret) {
setError(
"Both API key and secret are required (free at podcastindex.org)",
);
return;
}
setSaving(true);
const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch(
() => false,
);
setSaving(false);
if (!ok) {
// Keychain unavailable (non-macOS, locked, sandboxed): plaintext
// fallback on the source so the fallback search still works.
feedStore.updateSource("podcastindex", {
hasCredentials: true,
credentialStorage: "plaintext",
apiKey,
apiSecret,
enabled: true,
});
toast.show({
title: "Credentials stored in config.json",
message: "macOS keychain unavailable — API keys saved unencrypted.",
variant: "warning",
});
dialog.pop();
return;
}
feedStore.updateSource("podcastindex", {
hasCredentials: true,
credentialStorage: "keychain",
enabled: true,
});
dialog.pop();
};
return (
<box
border
title="Podcast Index API Keys"
padding={1}
flexDirection="column"
gap={1}
>
<text fg={theme.textMuted}>
Free key + secret from https://podcastindex.org/. Used as a
fallback when other sources return fewer than 3 results.
</text>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>API Key:</text>
<input
ref={(el: Renderable | null | undefined) => {
keyRef(el);
keyEl = el;
}}
value={key()}
onInput={setKey}
onSubmit={() => secretEl?.focus()}
placeholder="e.g. UXKCGDSYGUUEVQJSYDZH"
width={30}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>API Secret:</text>
<input
ref={(el: Renderable | null | undefined) => {
secretRef(el);
secretEl = el;
}}
value={secret()}
onInput={setSecret}
onSubmit={() => save()}
placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|"
width={40}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
<Show when={saving()}>
<text fg={theme.textMuted}>Storing credentials...</text>
</Show>
<text fg={theme.textMuted}>
[Enter] save · [Esc] cancel keys stay stored when disabled.
</text>
</box>
);
}

View File

@@ -11,6 +11,8 @@ 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 { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import {
loadFeedsFromFile,
saveFeedsToFile,
@@ -43,6 +45,50 @@ function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
* keychain, marking the source hasCredentials and stripping the plaintext.
* When the keychain is unavailable the plaintext stays (marked as the
* plaintext storage backend) so the source keeps working.
* Returns the same array when nothing needed migrating. */
async function migratePlaintextCredentials(
sources: PodcastSource[],
): Promise<PodcastSource[]> {
let changed = false;
const migrated: PodcastSource[] = [];
for (const source of sources) {
if (
source.id === "podcastindex" &&
source.apiKey &&
source.apiSecret &&
!source.hasCredentials
) {
const ok = await savePodcastIndexCredentials(
source.apiKey,
source.apiSecret,
);
if (ok) {
migrated.push({
...source,
apiKey: undefined,
apiSecret: undefined,
hasCredentials: true,
credentialStorage: "keychain",
});
} else {
migrated.push({
...source,
hasCredentials: true,
credentialStorage: "plaintext",
});
}
changed = true;
continue;
}
migrated.push(source);
}
return changed ? migrated : sources;
}
/** 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
@@ -196,6 +242,17 @@ function createFeedStore() {
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise<Feed | null> => {
// 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;
@@ -343,9 +400,24 @@ function createFeedStore() {
// too. User-added custom feeds keep their own ids and are untouched.
const migratedSources =
loadedSources?.filter((source) => source.id !== "rss") ?? [];
if (migratedSources.length > 0) {
setSources(migratedSources);
saveSources(migratedSources);
// Default sources fill gaps in persisted configs (so new defaults like
// the Podcast Index fallback reach existing installs), while a
// persisted source with the same id always wins over its default —
// user edits (keys, enabled, country) are never clobbered.
const mergedSources = [
...migratedSources,
...DEFAULT_SOURCES.filter(
(defaultSource) =>
!migratedSources.some((s) => s.id === defaultSource.id),
),
];
if (mergedSources.length > 0) {
// One-time credential migration: sources persisted with plaintext
// apiKey/apiSecret (pre-keychain builds) move into the macOS
// keychain and are stripped from config.json.
const secured = await migratePlaintextCredentials(mergedSources);
setSources(secured);
if (secured !== mergedSources) saveSources(secured);
}
await refreshAllFeeds();
})();
@@ -425,7 +497,7 @@ function createFeedStore() {
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (sourceId === "itunes") return false;
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);

View File

@@ -4,11 +4,12 @@
*/
import { createSignal } from "solid-js";
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
import type { SearchResult, SearchScope } from "../types/source";
const STORAGE_KEY = "podtui_search_history";
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 20;
export interface SearchState {
@@ -41,6 +42,27 @@ function saveHistory(history: string[]): void {
}
}
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
function loadScope(): SearchScope {
if (typeof localStorage === "undefined") return "podcast";
try {
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
return stored === "episode" ? "episode" : "podcast";
} catch {
return "podcast";
}
}
/** Save search scope to localStorage */
function saveScope(scope: SearchScope): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
} catch {
// Ignore errors
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
@@ -50,6 +72,13 @@ export function createSearchStore() {
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** Set the search scope (shows vs episodes) and persist it. */
const setScope = (next: SearchScope) => {
setScopeState(next);
saveScope(next);
};
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds();
@@ -110,9 +139,14 @@ export function createSearchStore() {
return;
}
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
const searchResults =
scope() === "episode"
? await searchEpisodes(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
: await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
setResults(applySubscribedStatus(searchResults));
} catch (e) {
@@ -187,6 +221,7 @@ export function createSearchStore() {
error,
history,
selectedSources,
scope,
// Actions
search,
@@ -195,6 +230,7 @@ export function createSearchStore() {
clearHistory,
removeFromHistory,
setSelectedSources,
setScope,
markSubscribed,
};
}

View File

@@ -12,8 +12,12 @@ export interface Podcast {
description: string
/** Cover image URL */
coverUrl?: string
/** RSS feed URL */
/** RSS feed URL. Empty when the directory lists the show without a feed
* (e.g. shows delisted from Apple Podcasts); see directoryUrl. */
feedUrl: string
/** Directory listing page (e.g. Apple Podcasts) for shows whose feed URL
* the directory omits — used to resolve the real feed at subscribe time. */
directoryUrl?: string
/** Author/creator name */
author?: string
/** Podcast categories */

View File

@@ -86,7 +86,7 @@ export type AppSettings = {
visualizer: VisualizerSettings;
};
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */

View File

@@ -2,6 +2,9 @@
* Podcast source type definitions for PodTUI
*/
import type { Episode } from "./episode"
import type { Podcast } from "./podcast"
/** Source type enumeration */
export enum SourceType {
/** RSS feed URL */
@@ -22,8 +25,21 @@ export interface PodcastSource {
type: SourceType
/** Base URL for the source */
baseUrl: string
/** API key (if required) */
/** API key — live only when the keychain is unavailable and the source
* uses the plaintext fallback (credentialStorage "plaintext"). Legacy
* plaintext keys are migrated to the OS keychain on load and stripped. */
apiKey?: string
/** API secret (e.g. Podcast Index signature auth) — same lifecycle as
* apiKey: held in the OS keychain by default, live on the source only
* under the plaintext fallback. */
apiSecret?: string
/** True when this source's credentials are stored. A source is usable once
* enabled. */
hasCredentials?: boolean
/** Where this source's credentials live: the OS keychain (encrypted at
* rest) by default, or config.json as a plaintext fallback when the
* keychain is unavailable (e.g. non-macOS). */
credentialStorage?: "keychain" | "plaintext"
/** Whether source is enabled */
enabled: boolean
/** Source icon/logo URL */
@@ -78,20 +94,39 @@ export enum SearchSortField {
POPULARITY = "popularity",
}
/** Search result */
export interface SearchResult {
/** What a directory search targets: shows or individual episodes. */
export type SearchScope = "podcast" | "episode"
/** Fields shared by every search result. */
export interface SearchResultBase {
/** Source that returned this result */
sourceId: string
/** Source display name */
sourceName?: string
/** Source type */
sourceType?: SourceType
/** Podcast data */
podcast: import("./podcast").Podcast
/** Relevance score (0-1) */
score?: number
}
/** A show found by directory search. */
export interface PodcastSearchResult extends SearchResultBase {
kind: "podcast"
/** Podcast data */
podcast: Podcast
}
/** A single episode found by directory search. `podcast` is its parent show
* — used for display context and for subscribing to the show. */
export interface EpisodeSearchResult extends SearchResultBase {
kind: "episode"
podcast: Podcast
episode: Episode
}
/** Search result */
export type SearchResult = PodcastSearchResult | EpisodeSearchResult
/** Default podcast sources */
export const DEFAULT_SOURCES: PodcastSource[] = [
{
@@ -105,4 +140,15 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
language: "en_us",
allowExplicit: true,
},
{
id: "podcastindex",
name: "Podcast Index",
type: SourceType.API,
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
enabled: false,
description:
"Open podcast directory. Fallback when other sources return few results; requires a free API key + secret from podcastindex.org.",
language: "en",
allowExplicit: true,
},
]

View File

@@ -71,6 +71,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"open",
"open-interactive",
"search",
"search-scope-toggle",
"filter",
"sort",
"toggle-hidden",

View File

@@ -0,0 +1,61 @@
/**
* iTunes feed resolution for shows delisted from Apple Podcasts.
*
* The iTunes Search API returns `feedUrl: null` for shows that left Apple
* Podcasts (e.g. The Daily Wire's shows in 2021) — the directory keeps a
* metadata-only stub. The show's public Apple Podcasts page still embeds the
* real feed URL in its JSON state (`showOffer.feedUrl`), so subscribing can
* resolve it from there.
*/
/** `"feedUrl":"https://..."` as embedded in the Apple page's JSON state. */
const FEED_URL_RE = /"feedUrl"\s*:\s*"(https?:\/\/[^"]+)"/
/**
* Extract the show's feed URL from an Apple Podcasts page's HTML.
*
* The page embeds `showOffer` blocks for the show AND for related shows, each
* with its own feedUrl, and Apple serves multiple JSON variants — the main
* show's showOffer may sit adjacent to its adamId or thousands of chars later.
* Anchor on the collection id from `directoryUrl` (`"adamId":"<id>"`) and take
* the FIRST feedUrl after it (the main show's content precedes related shows'
* in the document). Falls back to the first feedUrl in the document only when
* the id isn't present in the URL. Returns null when no trustworthy match
* exists (page restructured, no feed) — callers must not guess.
*/
export const extractFeedUrlFromPage = (
html: string,
directoryUrl: string,
): string | null => {
const idMatch = /[?/]id(\d+)/.exec(directoryUrl)
if (!idMatch) {
const fallback = FEED_URL_RE.exec(html)
return fallback ? fallback[1] : null
}
const adamIdx = html.search(new RegExp(`"adamId"\\s*:\\s*"${idMatch[1]}"`))
if (adamIdx < 0) return null
const fromAdam = new RegExp(FEED_URL_RE.source, "g")
fromAdam.lastIndex = adamIdx
const match = fromAdam.exec(html)
return match ? match[1] : null
}
/**
* Resolve a delisted show's RSS feed from its Apple Podcasts page.
* Returns null on network failure or when the page has no resolvable feed.
*/
export const resolveItunesFeedUrl = async (
directoryUrl: string,
): Promise<string | null> => {
try {
const response = await fetch(directoryUrl, {
headers: { "User-Agent": "PodTUI/1.0" },
})
if (!response.ok) return null
return extractFeedUrlFromPage(await response.text(), directoryUrl)
} catch {
return null
}
}

View File

@@ -60,6 +60,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
help: ["~", "f1"],
// list ops
search: ["s"],
"search-scope-toggle": ["tab"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],

View File

@@ -1,4 +1,4 @@
import { searchSourceByType } from "./source-searcher";
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source";
@@ -17,6 +17,12 @@ const rateLimitState = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60000;
const RATE_LIMIT_MAX_CALLS = 20;
/** Minimum results a primary search must return before the Podcast Index
* fallback runs — the open directory is only consulted when Apple's came up
* thin, exactly the case where it adds shows Apple lacks. */
const FALLBACK_MIN_RESULTS = 3;
const FALLBACK_SOURCE_ID = "podcastindex";
const throttleSource = async (sourceId: string) => {
const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS;
@@ -36,9 +42,9 @@ const throttleSource = async (sourceId: string) => {
rateLimitState.set(sourceId, updated);
};
const buildCacheKey = (query: string, sourceIds: string[]) => {
const buildCacheKey = (query: string, sourceIds: string[], prefix: string) => {
const keySources = [...sourceIds].sort().join(",");
return `${query.toLowerCase()}::${keySources}`;
return `${prefix}:${query.toLowerCase()}::${keySources}`;
};
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
@@ -47,8 +53,12 @@ const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>();
for (const result of results) {
// Episodes dedupe on the episode id; shows on feedUrl/id/title. The two
// scopes never mix within one result set, so keys can't collide.
const key =
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
result.kind === "episode"
? `episode:${result.episode.id}`
: result.podcast.feedUrl || result.podcast.id || result.podcast.title;
const existing = map.get(key);
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
map.set(key, result);
@@ -87,6 +97,7 @@ export const searchByFeedUrl = async (
sourceId: "direct-rss",
sourceName: "RSS Feed",
sourceType: SourceType.RSS,
kind: "podcast",
// parseRSSFeed marks feeds subscribed; a search result should start
// unsubscribed so the store can flag it correctly if already added.
podcast: { ...podcast, isSubscribed: false },
@@ -98,11 +109,20 @@ export const searchByFeedUrl = async (
}
};
export const searchPodcasts = async (
type SourceSearcher = (
query: string,
source: PodcastSource,
) => Promise<SearchResult[]>;
const searchSources = async (
query: string,
sourceIds: string[],
sources: PodcastSource[],
searcher: SourceSearcher,
cachePrefix: string,
options: SearchOptions = {},
/** Optional source id consulted as a low-result fallback (show scope only). */
fallbackSourceId?: string,
): Promise<SearchResult[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
@@ -124,6 +144,7 @@ export const searchPodcasts = async (
const cacheKey = buildCacheKey(
trimmed,
activeSources.map((s) => s.id),
cachePrefix,
);
const cached = searchCache.get(cacheKey);
if (cached && isCacheValid(cached, cacheTtl)) {
@@ -137,7 +158,7 @@ export const searchPodcasts = async (
activeSources.map(async (source) => {
try {
await throttleSource(source.id);
const sourceResults = await searchSourceByType(trimmed, source);
const sourceResults = await searcher(trimmed, source);
results.push(...sourceResults);
} catch (error) {
errors.push(error as Error);
@@ -146,7 +167,32 @@ export const searchPodcasts = async (
);
const deduped = dedupeResults(results);
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
let sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
// Low-result fallback: when the primary sources came back thin, consult
// the fallback source — but only when it's enabled AND keyed (a key-less
// default must never send requests) and it didn't already run as a primary
// source above. A fallback failure never sinks the primary results.
if (sorted.length < FALLBACK_MIN_RESULTS && fallbackSourceId) {
const fallback = sources.find(
(s) =>
s.id === fallbackSourceId &&
s.enabled &&
s.hasCredentials === true &&
!activeSources.includes(s),
);
if (fallback) {
try {
await throttleSource(fallback.id);
const fallbackResults = await searcher(trimmed, fallback);
sorted = dedupeResults([...sorted, ...fallbackResults]).sort(
(a, b) => (b.score ?? 0) - (a.score ?? 0),
);
} catch (error) {
errors.push(error as Error);
}
}
}
if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources");
@@ -156,4 +202,39 @@ export const searchPodcasts = async (
return sorted;
};
export const searchPodcasts = (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {},
): Promise<SearchResult[]> =>
searchSources(
query,
sourceIds,
sources,
searchSourceByType,
"show",
options,
FALLBACK_SOURCE_ID,
);
/** Episode-scope search: find individual episodes (e.g. a guest appearing
* across shows). Shares the source guard, rate limiting, and cache with
* searchPodcasts; the cache key is scoped separately so the two result
* kinds never collide for the same query. */
export const searchEpisodes = (
query: string,
sourceIds: string[],
sources: PodcastSource[],
options: SearchOptions = {},
): Promise<SearchResult[]> =>
searchSources(
query,
sourceIds,
sources,
searchEpisodesByType,
"episode",
options,
);

View File

@@ -0,0 +1,100 @@
/**
* Credential storage for keyed podcast sources.
*
* Preferred storage is the macOS keychain (encrypted at rest by the OS),
* written through the `security` CLI — no native dependencies. When the
* keychain is unavailable (non-macOS, locked, sandboxed) credentials fall
* back to plaintext on the source itself (config.json) so the source still
* works; `credentialStorage` on the source records which backend was used.
*
* Credentials are never presented in full — the UI always masks them (first
* 3 chars + "..."). The keychain password is passed as an argv value to
* `add-generic-password` (standard practice for CLI-driven keychain writes;
* the item lands in the login keychain immediately).
*/
import type { PodcastSource } from "../types/source"
const KEYCHAIN_SERVICE = "podtui"
const KEYCHAIN_ACCOUNT = "podcastindex"
export type Credentials = {
apiKey: string
apiSecret: string
}
/** Run a `security` subcommand; resolves with exit status + stdout. */
async function runSecurity(
args: string[],
): Promise<{ ok: boolean; stdout: string }> {
try {
const proc = Bun.spawn({
cmd: ["security", ...args],
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const exitCode = await proc.exited
return { ok: exitCode === 0, stdout }
} catch {
return { ok: false, stdout: "" }
}
}
/** Store Podcast Index credentials in the macOS keychain. True on success. */
export async function savePodcastIndexCredentials(
apiKey: string,
apiSecret: string,
): Promise<boolean> {
const payload = JSON.stringify({ apiKey, apiSecret })
const { ok } = await runSecurity([
"add-generic-password",
"-a",
KEYCHAIN_ACCOUNT,
"-s",
KEYCHAIN_SERVICE,
"-w",
payload,
"-U",
])
return ok
}
/** Read Podcast Index credentials from the macOS keychain. Null when absent
* or unreadable (non-macOS, item deleted, keychain locked). */
export async function loadPodcastIndexCredentials(): Promise<Credentials | null> {
const { ok, stdout } = await runSecurity([
"find-generic-password",
"-a",
KEYCHAIN_ACCOUNT,
"-s",
KEYCHAIN_SERVICE,
"-w",
])
if (!ok) return null
try {
const parsed = JSON.parse(stdout.trim()) as Credentials
if (!parsed.apiKey || !parsed.apiSecret) return null
return parsed
} catch {
return null
}
}
/** Resolve a source's stored credentials: its plaintext fields when saved
* with the plaintext fallback, else the macOS keychain. Null when the
* source has no usable credentials. */
export async function resolveSourceCredentials(
source: PodcastSource,
): Promise<Credentials | null> {
if (source.credentialStorage === "plaintext") {
return source.apiKey && source.apiSecret
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
: null
}
return loadPodcastIndexCredentials()
}

View File

@@ -1,6 +1,10 @@
import type { Podcast } from "../types/podcast"
import type { Episode } from "../types/episode"
import { SourceType } from "../types/source"
import type { PodcastSource, SearchResult } from "../types/source"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
import { resolveSourceCredentials } from "../utils/source-credentials"
type SearcherResult = SearchResult[]
@@ -14,11 +18,13 @@ type ItunesResult = {
collectionId?: number
collectionName?: string
artistName?: string
feedUrl?: string
/** Null for shows delisted from Apple Podcasts (directory stub records). */
feedUrl?: string | null
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
releaseDate?: string
collectionViewUrl?: string
}
type ItunesResponse = {
@@ -26,6 +32,26 @@ type ItunesResponse = {
results: ItunesResult[]
}
type ItunesEpisodeResult = {
trackId?: number
trackName?: string
collectionId?: number
collectionName?: string
artistName?: string
description?: string
/** Null for episodes of delisted shows (directory stub records). */
feedUrl?: string | null
episodeUrl?: string
/** Duration in milliseconds. */
trackTimeMillis?: number
releaseDate?: string
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
trackViewUrl?: string
collectionViewUrl?: string
}
const buildItunesUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
@@ -41,8 +67,167 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
return url.toString()
}
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName || !result.feedUrl) return null
/** Same as buildItunesUrl but targets episodes instead of shows — this is how
* guest/name searches find specific episodes (the term matches episode titles
* and show notes). */
const buildItunesEpisodeUrl = (query: string, source: PodcastSource) => {
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
const url = new URL(baseUrl)
const params = url.searchParams
params.set("term", query.trim())
params.set("media", "podcast")
params.set("entity", "podcastEpisode")
params.set("country", source.country ?? "US")
params.set("lang", source.language ?? "en_us")
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
return url.toString()
}
// ── Podcast Index (fallback directory) ─────────────────────────────────────
// Open, community-run directory that includes shows Apple never lists or has
// delisted. Requires a user-supplied key + secret (podcastindex.org) and is
// used only as a fallback when primary sources return few results (see
// search.ts). Feed-first: results carry the feed URL directly, so there is no
// delisted-show stub resolution step like iTunes has.
type PodcastIndexResult = {
id?: number
title?: string
/** Current feed URL. */
url?: string
/** Show website. */
link?: string
description?: string
author?: string
image?: string
artwork?: string
/** Unix epoch seconds of the feed's last update. */
lastUpdateTime?: number
/** Apple directory id when known (nullable — not all shows are on Apple). */
itunesId?: number | null
language?: string
explicit?: boolean
/** True when the feed is unreachable — drop these. */
dead?: boolean
episodeCount?: number
/** Category id -> name. */
categories?: Record<string, string>
newestItemPubdate?: number
}
type PodcastIndexResponse = {
status?: string | boolean
feeds?: PodcastIndexResult[]
}
const sha1Hex = async (input: string): Promise<string> => {
const data = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest("SHA-1", data)
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
/** Podcast Index auth is header-based: X-Auth-Key + X-Auth-Date (unix epoch
* seconds) + Authorization = sha1(key + secret + epoch). No query params.
* Credentials resolve from the source's storage backend: the OS keychain
* (encrypted at rest) by default, or the source's plaintext fields when the
* keychain was unavailable at save time. */
const buildPodcastIndexHeaders = async (
source: PodcastSource,
): Promise<Record<string, string>> => {
const credentials = await resolveSourceCredentials(source)
const key = credentials?.apiKey
const secret = credentials?.apiSecret
if (!key || !secret) {
throw new Error(
`${source.name} credentials are missing — enable the source in Settings → Sources to enter them`,
)
}
const epoch = Math.floor(Date.now() / 1000).toString()
const signature = await sha1Hex(key + secret + epoch)
return {
"User-Agent": "PodTUI/1.0",
"X-Auth-Key": key,
"X-Auth-Date": epoch,
Authorization: signature,
}
}
const buildPodcastIndexUrl = (query: string, source: PodcastSource) => {
const url = new URL(source.baseUrl)
url.searchParams.set("q", query.trim())
url.searchParams.set("max", "25")
return url.toString()
}
export const mapPodcastIndexResult = (
result: PodcastIndexResult,
source: PodcastSource,
): Podcast | null => {
if (!result.title || !result.url) return null
const id = result.id
? `podcastindex-${result.id}`
: `podcastindex-${slugify(result.title)}`
const descriptionParts = [result.title]
if (result.author) descriptionParts.push(`by ${result.author}`)
if (result.episodeCount !== undefined)
descriptionParts.push(`${result.episodeCount} episodes`)
return {
id,
title: result.title,
description: descriptionParts.join(" • "),
feedUrl: result.url,
author: result.author,
categories: result.categories
? Object.values(result.categories)
: undefined,
coverUrl: result.image || result.artwork,
language: result.language,
websiteUrl: result.link,
lastUpdated: result.lastUpdateTime
? new Date(result.lastUpdateTime * 1000)
: new Date(),
isSubscribed: false,
}
}
const searchPodcastIndexSource = async (
query: string,
source: PodcastSource,
): Promise<SearcherResult> => {
const headers = await buildPodcastIndexHeaders(source)
const response = await fetch(buildPodcastIndexUrl(query, source), {
headers,
})
if (!response.ok) {
throw new Error(`${source.name} search failed: ${response.status}`)
}
const data = (await response.json()) as PodcastIndexResponse
const results = (data.feeds ?? [])
.filter((item) => !item.dead)
.map((item) => mapPodcastIndexResult(item, source))
.filter((item): item is Podcast => Boolean(item))
return results.map((podcast, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "podcast" as const,
podcast,
score: 1 - index * 0.02,
}))
}
export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName) return null
const id = result.collectionId
? `itunes-${result.collectionId}`
@@ -52,11 +237,18 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
// Shows delisted from Apple Podcasts (e.g. The Daily Wire's shows) come back
// as metadata-only stub records with feedUrl null. Keep the stub so the show
// stays findable; the real feed is resolved from the directory page at
// subscribe time (see itunes-feed-resolver).
const feedUrl = result.feedUrl ?? ""
return {
id,
title: result.collectionName,
description: descriptionParts.join(" • "),
feedUrl: result.feedUrl,
feedUrl,
directoryUrl: feedUrl ? undefined : result.collectionViewUrl,
author: result.artistName,
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
coverUrl: result.artworkUrl600 || result.artworkUrl100,
@@ -65,7 +257,57 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
}
}
const searchAPISource = async (
/**
* Clean an iTunes description: detect HTML vs plain text and convert HTML to
* readable plain text (mirrors rss-parser's cleanField). iTunes show notes
* are often raw HTML.
*/
const cleanDescription = (raw: string): string => {
if (!raw) return ""
const decoded = raw
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
if (detectContentType(decoded) === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Map an iTunes episode record to a search result: the episode itself plus its
* parent show (as a Podcast) so subscribing works exactly like a show result.
* Returns null when the record is missing a track or collection name.
*/
export const mapItunesEpisodeResult = (
result: ItunesEpisodeResult,
source: PodcastSource,
): { podcast: Podcast; episode: Episode } | null => {
if (!result.trackName || !result.collectionName) return null
const podcast = mapItunesResult(result, source)
if (!podcast) return null
const episode: Episode = {
id: result.trackId
? `itunes-ep-${result.trackId}`
: `itunes-ep-${slugify(result.trackName)}`,
podcastId: podcast.id,
title: result.trackName,
description: cleanDescription(result.description ?? ""),
audioUrl: result.episodeUrl ?? "",
duration: result.trackTimeMillis
? Math.round(result.trackTimeMillis / 1000)
: 0,
pubDate: result.releaseDate ? new Date(result.releaseDate) : new Date(),
}
return { podcast, episode }
}
const searchItunesSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -73,7 +315,7 @@ const searchAPISource = async (
const response = await fetch(url)
if (!response.ok) {
throw new Error(`iTunes search failed: ${response.status}`)
throw new Error(`${source.name} search failed: ${response.status}`)
}
const data = (await response.json()) as ItunesResponse
@@ -85,11 +327,66 @@ const searchAPISource = async (
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "podcast" as const,
podcast,
score: 1 - index * 0.02,
}))
}
/** Dispatch API-source search by source id: iTunes is the primary directory,
* Podcast Index the user-configured fallback (also usable directly). */
const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
switch (source.id) {
case "podcastindex":
return searchPodcastIndexSource(query, source)
default:
return searchItunesSource(query, source)
}
}
const searchItunesEpisodeSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
const url = buildItunesEpisodeUrl(query, source)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${source.name} episode search failed: ${response.status}`)
}
const data = (await response.json()) as { results: ItunesEpisodeResult[] }
const results = data.results
.map((item) => mapItunesEpisodeResult(item, source))
.filter(
(item): item is { podcast: Podcast; episode: Episode } => Boolean(item),
)
return results.map(({ podcast, episode }, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "episode" as const,
podcast,
episode,
score: 1 - index * 0.02,
}))
}
/** Episode-scope API dispatch: only iTunes supports episode-by-term text
* search; Podcast Index has no such endpoint (its episode search is
* by-person only), so it contributes nothing to episode scope. */
const searchEpisodeAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.id === "podcastindex") return []
return searchItunesEpisodeSource(query, source)
}
/**
* RSS-type sources have no directory search backend: a feed URL identifies one
* show, and no API exists to search across "the RSS directory". Return no
@@ -115,3 +412,21 @@ export const searchSourceByType = async (
}
return searchAPISource(query, source)
}
/**
* Episode-scope dispatch: same backend rules as searchSourceByType — only
* API sources (iTunes) can search episodes; RSS/custom sources have no
* directory backend.
*/
export const searchEpisodesByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource()
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource()
}
return searchEpisodeAPISource(query, source)
}

View File

@@ -0,0 +1,143 @@
/**
* Per-feed pagination test — the store contract behind the "[Fetch More]"
* row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row.
*
* addFeed caches the FULL parsed feed while exposing only the first
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
* pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end.
* 3. loadMoreEpisodes past the end is a no-op (the feed is untouched).
*/
import { test, expect, beforeAll, afterAll } 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-pagination-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
interface ServedEpisode {
title: string;
date: string;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
// Bun runs test files in ONE process, so the store singleton is shared with
// feed-refresh.test.ts / feedless-subscribe.test.ts. 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) => `<item>
<title>${ep.title}</title>
<pubDate>${ep.date}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Paged Show</title>
<description>Pagination test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Paged Show",
description: "Pagination test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
});
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 });
},
});
});
afterAll(() => {
// 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 });
});
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
const store = useFeedStore();
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
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}/paged.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 more cached.
expect(store.getFeed(id)!.episodes.length).toBe(20);
expect(store.hasMoreEpisodes(id)).toBe(true);
// One load-more covers the remaining 40 (20 + 50 >= 60).
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(60);
expect(store.hasMoreEpisodes(id)).toBe(false);
// Exhausted: loadMoreEpisodes is a no-op — the feed object is untouched.
const before = store.getFeed(id)!;
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)).toBe(before);
});
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
const store = useFeedStore();
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
title: `Ep ${120 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(70);
expect(store.hasMoreEpisodes(id)).toBe(true);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(120);
expect(store.hasMoreEpisodes(id)).toBe(false);
});

View File

@@ -0,0 +1,117 @@
/**
* End-to-end subscribe test for feedless directory stubs.
*
* A delisted show (feedUrl "" + directoryUrl) must resolve its real feed from
* the directory page inside addFeed — so subscribing just works. When the
* page can't be resolved, addFeed must refuse (return null) instead of adding
* a broken feed. Served over a real local HTTP server, mirroring how the
* app's other store tests exercise the network path.
*/
import { test, expect, beforeAll, afterAll } 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-feedless-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const FEED_ID = "12345";
const EPISODE_TITLES = ["Ep 2", "Ep 1"];
function pageHtml(feedUrl: string): string {
return `<html><body><script>
{"pageData":{"showOffer":{"title":"Delisted Show","adamId":"${FEED_ID}","feedUrl":"${feedUrl}","showType":"episodic"}}}
</script></body></html>`;
}
function feedXml(origin: string): string {
const items = EPISODE_TITLES.map(
(title, i) => `<item>
<title>${title}</title>
<pubDate>2026-08-0${2 - i}T00:00:00Z</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Delisted Show</title>
<description>Feedless stub test</description>
${items}
</channel></rss>`;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let feedUrl = "";
let pageUrl = "";
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".rss")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
if (url.pathname.startsWith("/show")) {
return new Response(pageHtml(feedUrl), {
headers: { "Content-Type": "text/html" },
});
}
return new Response("not found", { status: 404 });
},
});
feedUrl = `http://127.0.0.1:${server!.port}/feed.rss`;
// The resolver anchors on `/id<digits>` in the directory URL.
pageUrl = `http://127.0.0.1:${server!.port}/show/id${FEED_ID}`;
});
afterAll(() => {
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
const makeStub = (feedUrl: string, directoryUrl?: string): Podcast => ({
id: "itunes-12345",
title: "Delisted Show",
description: "Show that left the directory",
author: "Some Network",
feedUrl,
directoryUrl,
lastUpdated: new Date(),
isSubscribed: false,
});
test("addFeed resolves a feedless stub's feed from its directory page", async () => {
const store = useFeedStore();
const feed = await store.addFeed(makeStub("", pageUrl), "itunes");
expect(feed).not.toBeNull();
expect(feed!.podcast.feedUrl).toBe(feedUrl);
// Resolution metadata is dropped from the persisted feed record.
expect(feed!.podcast.directoryUrl).toBeUndefined();
expect(feed!.episodes.map((e) => e.title)).toEqual(EPISODE_TITLES);
// Remove the feed: bun test shares the store singleton across files, and a
// leftover feed (whose server dies in afterAll) would reorder other files'
// refresh assertions.
store.removeFeed(feed!.id);
});
test("addFeed refuses a stub whose directory page cannot be resolved", async () => {
const store = useFeedStore();
const unreachable = makeStub("", "http://127.0.0.1:1/nope/id999");
const feed = await store.addFeed(unreachable, "itunes");
expect(feed).toBeNull();
});
test("addFeed refuses a stub with no directory page at all", async () => {
const store = useFeedStore();
const bare = makeStub("", undefined);
const feed = await store.addFeed(bare, "itunes");
expect(feed).toBeNull();
});

View File

@@ -0,0 +1,70 @@
/**
* Feed-resolution extraction tests.
*
* The iTunes Search API returns feedUrl null for shows delisted from Apple
* Podcasts. Their public Apple page still embeds the real feed URL in JSON
* state — alongside feedUrls of RELATED shows — so extraction must anchor on
* the show's adamId rather than grabbing the first feedUrl in the document.
*/
import { test, expect } from "bun:test";
import { extractFeedUrlFromPage } from "../src/utils/itunes-feed-resolver";
const MAIN_FEED = "https://rss.pdrl.fm/b32227/feeds.megaphone.fm/BVDWV5370667266";
const OTHER_FEED = "https://feeds.megaphone.fm/BVDWV7762869899";
/** Synthetic Apple page: related shows first, main showOffer after. */
const pageWithNoise = `{
"shows":[{"showOffer":{"title":"The Matt Walsh Show","adamId":"2950206264","feedUrl":"${OTHER_FEED}","showType":"episodic"}}],
"pageData":{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}","showType":"episodic"}}
}`;
test("extracts the show's feed anchored on its adamId, ignoring related shows", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBe(MAIN_FEED);
});
test("handles the ?uo=4 suffix Apple appends to directory URLs", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260?uo=4",
);
expect(feed).toBe(MAIN_FEED);
});
test("returns null when the page has no feedUrl for the requested id", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/some-other-show/id9999999999",
);
expect(feed).toBeNull();
});
test("finds the feed when the showOffer sits far after the adamId reference", () => {
// Apple serves page variants where thousands of chars separate the first
// adamId reference from the showOffer block carrying the feedUrl.
const variant = `{"adamId":"1047335260","$kind":"ShowPageIntent"}${"x".repeat(6000)}{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}"}}`;
const feed = extractFeedUrlFromPage(
variant,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBe(MAIN_FEED);
});
test("falls back to the first feedUrl when the URL carries no id", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show",
);
expect(feed).toBe(OTHER_FEED);
});
test("returns null when the page contains no feedUrl at all", () => {
const feed = extractFeedUrlFromPage(
"<html><body>not found</body></html>",
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBeNull();
});

View File

@@ -0,0 +1,354 @@
/**
* Podcast Index fallback tests.
*
* The Podcast Index source ships disabled and key-less. It must only be
* consulted as a low-result fallback (fewer than 3 primary results), only
* when enabled AND its credentials are stored (hasCredentials), and never
* twice when also selected as a primary source. Credentials prefer the OS
* keychain (encrypted at rest); when the keychain is unavailable they fall
* back to plaintext on the source (config.json). Auth follows the documented
* scheme: X-Auth-Key, X-Auth-Date (unix epoch), Authorization =
* sha1(key + secret + date).
*/
import { test, expect, mock, afterEach } from "bun:test";
import { searchPodcasts } from "../src/utils/search";
import {
searchSourceByType,
searchEpisodesByType,
mapPodcastIndexResult,
} from "../src/utils/source-searcher";
import { SourceType } from "../src/types/source";
import type { PodcastSource } from "../src/types/source";
// The searcher pulls credentials from the credential-storage module;
// mock.module is hoisted above the imports, so this stub lands before
// source-searcher loads. resolveSourceCredentials mirrors the real resolver
// (plaintext branch vs keychain branch) against the mutable keychainState.
const keychainState: {
credentials: { apiKey: string; apiSecret: string } | null;
} = {
credentials: { apiKey: "TESTKEY123", apiSecret: "TESTSECRET456" },
};
mock.module("../src/utils/source-credentials", () => ({
savePodcastIndexCredentials: async () => true,
loadPodcastIndexCredentials: async () => keychainState.credentials,
resolveSourceCredentials: async (source: PodcastSource) =>
source.credentialStorage === "plaintext"
? source.apiKey && source.apiSecret
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
: null
: keychainState.credentials,
}));
const sha1 = (input: string): string =>
Bun.CryptoHasher.hash("sha1", input, "hex") as string;
const itunesSource: PodcastSource = {
id: "itunes",
name: "Apple Podcasts",
type: SourceType.API,
baseUrl: "https://itunes.apple.com/search",
enabled: true,
};
const keyedPodcastIndex: PodcastSource = {
id: "podcastindex",
name: "Podcast Index",
type: SourceType.API,
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
enabled: true,
hasCredentials: true,
};
const PI_URL = "https://api.podcastindex.org/api/1.0/search/byterm";
const ITUNES_URL = "https://itunes.apple.com/search";
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
const itunesResults = (count: number) =>
Array.from({ length: count }, (_, i) => ({
collectionId: i + 1,
collectionName: `Show ${i + 1}`,
feedUrl: `https://example.com/feed${i + 1}.xml`,
}));
/** Route iTunes vs Podcast Index fetches; records call URLs. */
const routeFetch = (
calls: string[],
opts: { itunesCount?: number; piFeeds?: unknown[]; piStatus?: number } = {},
) =>
mock(async (url: RequestInfo | URL, _init?: RequestInit) => {
const u = String(url);
calls.push(u);
if (u.startsWith(ITUNES_URL)) {
const count = opts.itunesCount ?? 0;
return jsonResponse({ resultCount: count, results: itunesResults(count) });
}
if (u.startsWith(PI_URL)) {
if (opts.piStatus && opts.piStatus >= 400) {
return new Response("nope", { status: opts.piStatus });
}
const feeds = opts.piFeeds ?? [];
return jsonResponse({ status: "true", feeds, count: feeds.length });
}
throw new Error(`unexpected fetch: ${u}`);
}) as unknown as typeof fetch;
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
// ── result mapping ───────────────────────────────────────────────────────────
test("Podcast Index results map to podcasts with the feed URL direct", () => {
const mapped = mapPodcastIndexResult(
{
id: 42,
title: "Fallback Show",
url: "https://example.com/feed2.xml",
author: "Open Directory",
image: "https://example.com/art.jpg",
language: "en",
episodeCount: 10,
lastUpdateTime: 1600000000,
categories: { "104": "Technology", "105": "News" },
},
keyedPodcastIndex,
);
expect(mapped).not.toBeNull();
expect(mapped!.id).toBe("podcastindex-42");
expect(mapped!.title).toBe("Fallback Show");
// PI is feed-first: the feed URL is present, no delisted-show stub step.
expect(mapped!.feedUrl).toBe("https://example.com/feed2.xml");
expect(mapped!.directoryUrl).toBeUndefined();
expect(mapped!.categories).toEqual(["Technology", "News"]);
expect(mapped!.lastUpdated.toISOString()).toBe("2020-09-13T12:26:40.000Z");
expect(mapped!.isSubscribed).toBe(false);
});
test("Podcast Index results without a title or feed URL stay dropped", () => {
expect(mapPodcastIndexResult({ id: 1 }, keyedPodcastIndex)).toBeNull();
expect(
mapPodcastIndexResult({ title: "No Feed" }, keyedPodcastIndex),
).toBeNull();
});
// ── auth scheme ──────────────────────────────────────────────────────────────
test("Podcast Index auth uses sha1(key+secret+date) from keychain credentials", async () => {
let captured: RequestInit | undefined;
globalThis.fetch = mock(
async (url: RequestInfo | URL, init?: RequestInit) => {
if (String(url).startsWith(PI_URL)) {
captured = init;
return jsonResponse({
status: "true",
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
count: 1,
});
}
return jsonResponse({ resultCount: 0, results: [] });
},
) as unknown as typeof fetch;
await searchSourceByType("hello", keyedPodcastIndex);
const headers = captured?.headers as Record<string, string>;
expect(headers["X-Auth-Key"]).toBe("TESTKEY123");
expect(headers["User-Agent"]).toBe("PodTUI/1.0");
expect(headers["X-Auth-Date"]).toMatch(/^\d{10}$/);
expect(headers["Authorization"]).toBe(
sha1(`TESTKEY123TESTSECRET456${headers["X-Auth-Date"]}`),
);
});
test("plaintext-stored credentials drive the search without the keychain", async () => {
keychainState.credentials = null;
let captured: RequestInit | undefined;
globalThis.fetch = mock(
async (url: RequestInfo | URL, init?: RequestInit) => {
if (String(url).startsWith(PI_URL)) {
captured = init;
return jsonResponse({
status: "true",
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
count: 1,
});
}
return jsonResponse({ resultCount: 0, results: [] });
},
) as unknown as typeof fetch;
const plaintext: PodcastSource = {
...keyedPodcastIndex,
credentialStorage: "plaintext",
apiKey: "PLAINTEXTKEY",
apiSecret: "PLAINTEXTSECRET",
};
try {
await searchSourceByType("hello", plaintext);
} finally {
keychainState.credentials = {
apiKey: "TESTKEY123",
apiSecret: "TESTSECRET456",
};
}
const headers = captured?.headers as Record<string, string>;
expect(headers["X-Auth-Key"]).toBe("PLAINTEXTKEY");
expect(headers["Authorization"]).toBe(
sha1(`PLAINTEXTKEYPLAINTEXTSECRET${headers["X-Auth-Date"]}`),
);
});
test("missing keychain credentials fail with a setup message, not a request", async () => {
keychainState.credentials = null;
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, { itunesCount: 0 });
try {
await expect(searchSourceByType("hello", keyedPodcastIndex)).rejects.toThrow(
/credentials are missing/,
);
} finally {
keychainState.credentials = {
apiKey: "TESTKEY123",
apiSecret: "TESTSECRET456",
};
}
expect(calls).toEqual([]);
});
test("Podcast Index has no episode-scope search backend", async () => {
const results = await searchEpisodesByType("hello", keyedPodcastIndex);
expect(results).toEqual([]);
});
// ── fallback behavior ────────────────────────────────────────────────────────
test("thin primary results trigger the keyed Podcast Index fallback", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, {
itunesCount: 1,
piFeeds: [
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
],
});
const results = await searchPodcasts(
"unique-query-thin",
["itunes"],
[itunesSource, keyedPodcastIndex],
);
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(true);
const pi = results.find((r) => r.sourceId === "podcastindex");
expect(pi?.sourceName).toBe("Podcast Index");
expect(pi?.podcast.title).toBe("Fallback Show");
expect(results.length).toBe(2);
});
test("fallback is skipped when primary results meet the threshold", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, { itunesCount: 5 });
const results = await searchPodcasts(
"unique-query-full",
["itunes"],
[itunesSource, keyedPodcastIndex],
);
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
expect(results.length).toBe(5);
});
test("a disabled Podcast Index source is never consulted", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
const disabled = { ...keyedPodcastIndex, enabled: false };
await searchPodcasts(
"unique-query-disabled",
["itunes"],
[itunesSource, disabled],
);
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
});
test("a credential-less Podcast Index source never sends requests", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
const keyless = { ...keyedPodcastIndex, hasCredentials: false };
await searchPodcasts(
"unique-query-keyless",
["itunes"],
[itunesSource, keyless],
);
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
});
test("Podcast Index selected as a primary source is fetched once, not twice", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, {
itunesCount: 1,
piFeeds: [
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
],
});
await searchPodcasts(
"unique-query-both",
["itunes", "podcastindex"],
[itunesSource, keyedPodcastIndex],
);
expect(calls.filter((u) => u.startsWith(PI_URL)).length).toBe(1);
});
test("a failing fallback leaves the primary results intact", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, { itunesCount: 1, piStatus: 500 });
const results = await searchPodcasts(
"unique-query-pi-fail",
["itunes"],
[itunesSource, keyedPodcastIndex],
);
expect(results.length).toBe(1);
expect(results[0].sourceId).toBe("itunes");
});
test("dead Podcast Index feeds are filtered out", async () => {
const calls: string[] = [];
globalThis.fetch = routeFetch(calls, {
itunesCount: 0,
piFeeds: [
{ id: 1, title: "Live Show", url: "https://example.com/live.xml" },
{
id: 2,
title: "Dead Show",
url: "https://example.com/dead.xml",
dead: true,
},
],
});
const results = await searchPodcasts(
"unique-query-dead",
["itunes"],
[itunesSource, keyedPodcastIndex],
);
const pi = results.filter((r) => r.sourceId === "podcastindex");
expect(pi.length).toBe(1);
expect(pi[0].podcast.title).toBe("Live Show");
});

267
tests/search-focus.test.tsx Normal file
View File

@@ -0,0 +1,267 @@
/**
* search-focus.test.tsx — the search query input's focus flag must follow its
* REAL focus, not a flag that outlives it.
*
* Regression: clicking off the query input (opentui's mouse dispatch
* auto-focuses the clicked target's nearest focusable ancestor, blurring the
* input) used to leave `nav.inputFocused()` stuck true while the renderable
* was unfocused. The Shell keyboard router then yielded every key to a
* non-existent input: Esc wouldn't defocus, `s` wouldn't refocus, j/k/h did
* nothing. The input now drives the flag through useInputFocusNav
* (FOCUSED/BLURRED), so click-off drops the flag and keyboard control resumes;
* clicking the input focuses it.
*
* Mounts the real app (sandboxed, silent audio) via the same provider tree as
* scripts/tui-harness.tsx and drives it with the test renderer's mock keys +
* mouse. Layout is deterministic at 100x30: tab list 20 cols, current pane
* x=20..69 (content padded 1), so the query input row is y=1, x=29..57 and the
* pane interior is blank at (60,5).
*
* KNOWN FLAKE (full-suite CPU contention): both tests occasionally fail inside
* openSearch — the tab-digit press or the post-Enter input focus doesn't land
* within the retry budget (20 × press+render+40ms, then a 5s waitFor). Passes
* reliably in isolation (`bun test tests/search-focus.test.tsx`, ~2s).
* Observed 2026-08-11 on an M3 Pro: two consecutive red full-suite runs
* (`timed out waiting for: input focused on tab entry` / `Expected: 4,
* Received: 1`), then two consecutive green — timing under load, not state.
* If a full-suite run reports these two, re-run the file alone to confirm
* before treating it as a regression.
*/
import { test, expect, afterAll, mock } from "bun:test";
import { testRender } from "@opentui/solid";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import type { AudioControls } from "../src/hooks/useAudio";
// Other test files that `mock.module("../src/hooks/useAudio")` can share this
// file's worker (bun reuses workers; the mock leaks into the module registry
// here). That stub only has duration/position/seek, so the Shell's render
// throws on audio.currentEpisode() and no key router ever attaches — presses
// go nowhere. Register a complete no-op stub FIRST so the app mounts
// regardless of what leaked in before us.
const stubAudio: AudioControls = {
isPlaying: () => false,
position: () => 0,
duration: () => 0,
volume: () => 1,
speed: () => 1,
backendName: () => "none",
error: () => null,
currentEpisode: () => null,
availablePlayers: () => [],
play: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},
stop: async () => {},
seek: async () => {},
seekRelative: async () => {},
setVolume: async () => {},
setSpeed: async () => {},
switchBackend: async () => {},
prev: async () => {},
next: async () => {},
};
mock.module("../src/hooks/useAudio", () => ({
useAudio: () => stubAudio,
}));
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
// env vars at import time. Static imports are hoisted above this code, so the
// app modules must be loaded dynamically (mirrors scripts/tui-harness.tsx).
const SANDBOX = join(process.cwd(), ".harness", "test-focus");
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
process.env.PODTUI_AUDIO_BACKEND = "none";
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider, useKeybinds } = await import(
"../src/context/KeybindContext"
);
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
const { TABS } = await import("../src/utils/navigation");
// Click coordinates (0-based) in the 100x30 layout — see file header.
const INPUT_CLICK = { x: 35, y: 1 };
const PILL_EPISODES = { x: 44, y: 3 };
const BLANK_PANE = { x: 60, y: 5 };
type Mounted = {
renderer: any;
renderOnce: () => Promise<void>;
mockInput: any;
mockMouse: any;
nav: () => {
inputFocused: () => boolean;
currentDepth: () => number;
activeTab: () => number;
};
keybindsReady: () => boolean;
};
/** Mount the real app and return drivers + nav/keybind probes (contexts are
* only readable inside the provider tree). */
async function mountApp(): Promise<Mounted> {
let navRef: any = null;
let keybindsRef: any = null;
const StateProbe = () => {
navRef = useNavigation();
keybindsRef = useKeybinds();
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width: 100,
height: 30,
useThread: false,
});
(setup.renderer as unknown as { disableStdoutInterception?: () => void }).disableStdoutInterception?.();
// Initial mount settle; keybind readiness is awaited separately in
// settleReady before any keypress.
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
return {
renderer: setup.renderer,
renderOnce: setup.renderOnce,
mockInput: setup.mockInput,
mockMouse: setup.mockMouse,
nav: () => navRef,
keybindsReady: () => keybindsRef?.ready ?? false,
};
}
/** Render + settle until the keybind router is ready (it loads keybinds from
* disk asynchronously on mount); the first keypress would otherwise be lost
* under full-suite CPU contention. */
async function settleReady(m: Mounted): Promise<void> {
for (let i = 0; i < 80; i++) {
await m.renderOnce();
await new Promise((r) => setTimeout(r, 60));
if (m.keybindsReady()) return;
}
throw new Error("keybinds never became ready");
}
/** Navigate to the Search tab's query depth (input auto-focuses on entry).
* The tab-digit press is retried until it lands: the first press can fire
* before the Shell's key router attaches (keybinds load asynchronously), so
* re-pressing self-heals instead of flaking under suite contention. */
async function openSearch(m: Mounted): Promise<void> {
await settleReady(m);
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) {
m.mockInput.pressKey("4");
await m.renderOnce();
await new Promise((r) => setTimeout(r, 40));
}
expect(m.nav().activeTab()).toBe(TABS.SEARCH);
m.mockInput.pressEnter();
await waitFor(m, () => m.nav().inputFocused(), "input focused on tab entry");
}
/** Render + sleep until `cond` holds or the timeout elapses. Fixed sleeps after
* input/mouse actions flake under full-suite CPU contention, so state
* transitions are polled instead. */
async function waitFor(
m: Mounted,
cond: () => boolean,
what: string,
timeoutMs = 5000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cond()) return;
await m.renderOnce();
await new Promise((r) => setTimeout(r, 25));
}
throw new Error(`timed out waiting for: ${what}`);
}
/** The renderable the renderer currently considers focused (if any). */
function focusedName(m: Mounted): string | null {
const r = m.renderer.currentFocusedRenderable;
return r ? `${r.constructor?.name} id=${r.id}` : null;
}
afterAll(() => {
rmSync(SANDBOX, { recursive: true, force: true });
});
test("clicking off the query input drops inputFocused; s and Esc restore keyboard control", async () => {
const m = await mountApp();
try {
await openSearch(m);
expect(m.nav().inputFocused()).toBe(true);
// Click off the input (scope pill). Real focus moves to the pane's
// scrollbox, blurring the input — the flag must follow.
await m.mockMouse.click(PILL_EPISODES.x, PILL_EPISODES.y);
await waitFor(m, () => !m.nav().inputFocused(), "flag off after pill click");
expect(focusedName(m)).not.toMatch(/^Input/);
// `s` (search action) must refocus the input for typing.
m.mockInput.pressKey("s");
await waitFor(m, () => m.nav().inputFocused(), "s refocuses the input");
expect(focusedName(m)).toMatch(/^Input/);
// Escape defocuses; the flag stays off (no depth change re-seeds it).
m.mockInput.pressEscape();
await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses");
// Click-off a second time (blank pane interior). The flag is already
// false from the escape, so a state-poll can't observe the click; check
// directly that the click did NOT re-focus the input, then that j is
// not swallowed (flag stays false, nothing re-grabs focus).
await m.mockMouse.click(BLANK_PANE.x, BLANK_PANE.y);
await m.renderOnce();
await new Promise((r) => setTimeout(r, 40));
expect(m.nav().inputFocused()).toBe(false);
m.mockInput.pressKey("j");
await m.renderOnce();
await new Promise((r) => setTimeout(r, 40));
expect(m.nav().inputFocused()).toBe(false);
} finally {
m.renderer.destroy();
}
});
test("clicking the query input focuses it (typing mode)", async () => {
const m = await mountApp();
try {
await openSearch(m);
// Defocus first so the click has something to restore.
m.mockInput.pressEscape();
await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses");
await m.mockMouse.click(INPUT_CLICK.x, INPUT_CLICK.y);
await waitFor(m, () => m.nav().inputFocused(), "input click focuses");
expect(focusedName(m)).toMatch(/^Input/);
} finally {
m.renderer.destroy();
}
});

View File

@@ -0,0 +1,46 @@
/**
* Credential resolution tests against the real module (no mocks).
*
* Only the plaintext branch is exercised: the keychain branch spawns the
* `security` CLI and would depend on the host machine's keychain state
* (the keychain-backed pipeline is covered in podcastindex-fallback.test.ts
* with a stubbed module). The plaintext branch must never touch the
* keychain — it is the fallback that keeps the source working on machines
* without a usable macOS keychain.
*/
import { test, expect } from "bun:test";
import { resolveSourceCredentials } from "../src/utils/source-credentials";
import { SourceType } from "../src/types/source";
import type { PodcastSource } from "../src/types/source";
const base: PodcastSource = {
id: "podcastindex",
name: "Podcast Index",
type: SourceType.API,
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
enabled: true,
hasCredentials: true,
};
test("plaintext-storage sources resolve their own fields, no keychain call", async () => {
const source: PodcastSource = {
...base,
credentialStorage: "plaintext",
apiKey: "PLAINTEXTKEY",
apiSecret: "PLAINTEXTSECRET",
};
expect(await resolveSourceCredentials(source)).toEqual({
apiKey: "PLAINTEXTKEY",
apiSecret: "PLAINTEXTSECRET",
});
});
test("plaintext-storage sources with empty fields resolve to null", async () => {
const source: PodcastSource = {
...base,
credentialStorage: "plaintext",
apiKey: undefined,
apiSecret: undefined,
};
expect(await resolveSourceCredentials(source)).toBeNull();
});

View File

@@ -9,7 +9,12 @@
* search. This test pins the empty-result contract.
*/
import { test, expect } from "bun:test";
import { searchSourceByType } from "../src/utils/source-searcher";
import {
searchSourceByType,
searchEpisodesByType,
mapItunesResult,
mapItunesEpisodeResult,
} from "../src/utils/source-searcher";
import { SourceType } from "../src/types/source";
import type { PodcastSource } from "../src/types/source";
@@ -29,6 +34,16 @@ const customSource: PodcastSource = {
enabled: true,
};
const itunesSource: PodcastSource = {
id: "itunes",
name: "Apple Podcasts",
type: SourceType.API,
baseUrl: "https://itunes.apple.com/search",
enabled: true,
country: "US",
language: "en_us",
};
test("RSS sources return no directory search results", async () => {
const results = await searchSourceByType("blocked and reported", rssSource);
expect(results).toEqual([]);
@@ -38,3 +53,153 @@ test("custom sources return no directory search results", async () => {
const results = await searchSourceByType("anything", customSource);
expect(results).toEqual([]);
});
// ── iTunes stub records (delisted shows) ────────────────────────────────────
// Shows that left Apple Podcasts (e.g. The Daily Wire's in 2021) remain in
// the directory as metadata-only records with feedUrl null. They must stay
// findable — earlier they were dropped entirely, so "ben shapiro" surfaced
// nothing while the show is the #1 iTunes hit.
test("iTunes results without a feedUrl (delisted shows) are kept, not dropped", () => {
const stub = mapItunesResult(
{
collectionId: 1047335260,
collectionName: "The Ben Shapiro Show",
artistName: "The Daily Wire",
feedUrl: null,
collectionViewUrl:
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
},
itunesSource,
);
expect(stub).not.toBeNull();
expect(stub!.title).toBe("The Ben Shapiro Show");
// Empty feed marks "unavailable from this directory"; the Apple page URL
// is carried for feed resolution at subscribe time.
expect(stub!.feedUrl).toBe("");
expect(stub!.directoryUrl).toBe(
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
});
test("iTunes results with a feedUrl keep it and carry no directory fallback", () => {
const normal = mapItunesResult(
{
collectionId: 1487234816,
collectionName: "Morning Wire",
artistName: "The Daily Wire",
feedUrl: "https://feeds.megaphone.fm/BVDWV8747925072",
},
itunesSource,
);
expect(normal).not.toBeNull();
expect(normal!.feedUrl).toBe("https://feeds.megaphone.fm/BVDWV8747925072");
expect(normal!.directoryUrl).toBeUndefined();
});
test("iTunes results without a collection name stay dropped", () => {
const dropped = mapItunesResult(
{ collectionId: 1, feedUrl: "https://example.com/feed.xml" },
itunesSource,
);
expect(dropped).toBeNull();
});
// ── Episode search (entity=podcastEpisode) ──────────────────────────────────
// Episode scope lets a query find a specific episode — e.g. a guest appearing
// across shows — instead of only whole shows.
test("episode results map to an episode plus its parent show", () => {
const mapped = mapItunesEpisodeResult(
{
trackId: 1000000000001,
trackName: "Sam Altman on AGI, energy, and the future of work",
collectionId: 1434243584,
collectionName: "Lex Fridman Podcast",
artistName: "Lex Fridman",
description: "Sam Altman joins the show to talk about AGI.",
feedUrl: "https://lexfridman.com/feed/podcast",
episodeUrl: "https://lexfridman.com/audio/ep-434.mp3",
trackTimeMillis: 3600000,
releaseDate: "2025-02-01T08:00:00Z",
artworkUrl600: "https://example.com/art600.jpg",
primaryGenreName: "Technology",
},
itunesSource,
);
expect(mapped).not.toBeNull();
const { podcast, episode } = mapped!;
// Episode fields: id namespaced, duration ms → seconds, date parsed.
expect(episode.id).toBe("itunes-ep-1000000000001");
expect(episode.podcastId).toBe(podcast.id);
expect(episode.title).toBe("Sam Altman on AGI, energy, and the future of work");
expect(episode.audioUrl).toBe("https://lexfridman.com/audio/ep-434.mp3");
expect(episode.duration).toBe(3600);
expect(episode.pubDate.toISOString()).toBe("2025-02-01T08:00:00.000Z");
// Parent show carries the feed for subscribing, like a show result.
expect(podcast.title).toBe("Lex Fridman Podcast");
expect(podcast.id).toBe("itunes-1434243584");
expect(podcast.feedUrl).toBe("https://lexfridman.com/feed/podcast");
expect(podcast.isSubscribed).toBe(false);
});
test("episode results strip HTML from descriptions", () => {
const mapped = mapItunesEpisodeResult(
{
trackName: "Episode with HTML notes",
collectionName: "Some Show",
description: "<p>Guest: <strong>Jane Doe</strong></p><p>Topic: AI.</p>",
feedUrl: "https://example.com/feed.xml",
},
itunesSource,
);
expect(mapped).not.toBeNull();
const desc = mapped!.episode.description;
expect(desc).toContain("Jane Doe");
expect(desc).not.toContain("<");
expect(desc).not.toContain(">");
});
test("episode results without a track name stay dropped", () => {
const dropped = mapItunesEpisodeResult(
{ collectionId: 1, collectionName: "Some Show" },
itunesSource,
);
expect(dropped).toBeNull();
});
test("episode results without a collection name stay dropped", () => {
const dropped = mapItunesEpisodeResult(
{ trackId: 1, trackName: "Some Episode" },
itunesSource,
);
expect(dropped).toBeNull();
});
test("episode results of delisted shows keep a directory fallback on the show", () => {
const mapped = mapItunesEpisodeResult(
{
trackId: 2,
trackName: "An episode",
collectionId: 1047335260,
collectionName: "The Ben Shapiro Show",
artistName: "The Daily Wire",
feedUrl: null,
collectionViewUrl:
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
},
itunesSource,
);
expect(mapped).not.toBeNull();
expect(mapped!.podcast.feedUrl).toBe("");
expect(mapped!.podcast.directoryUrl).toBe(
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
});
test("RSS and custom sources return no episode search results either", async () => {
expect(await searchEpisodesByType("sam altman", rssSource)).toEqual([]);
expect(await searchEpisodesByType("sam altman", customSource)).toEqual([]);
});