Compare commits
7 Commits
ebed49237c
...
b2e9e5c16c
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e9e5c16c | |||
| 41c0002090 | |||
| c0252fc9b8 | |||
| 0c3506beb5 | |||
| ef9fc13aaa | |||
| 0b0637b9dc | |||
| e73e608b9f |
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -63,6 +63,7 @@ export type KeybindActionName =
|
||||
| "quit"
|
||||
| "help"
|
||||
| "search"
|
||||
| "search-scope-toggle"
|
||||
| "filter"
|
||||
| "sort"
|
||||
| "toggle-hidden"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -71,6 +71,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
"open",
|
||||
"open-interactive",
|
||||
"search",
|
||||
"search-scope-toggle",
|
||||
"filter",
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
|
||||
61
src/utils/itunes-feed-resolver.ts
Normal file
61
src/utils/itunes-feed-resolver.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
help: ["~", "f1"],
|
||||
// list ops
|
||||
search: ["s"],
|
||||
"search-scope-toggle": ["tab"],
|
||||
filter: ["f"],
|
||||
sort: [","],
|
||||
"toggle-hidden": ["."],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
|
||||
100
src/utils/source-credentials.ts
Normal file
100
src/utils/source-credentials.ts
Normal 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()
|
||||
}
|
||||
@@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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)
|
||||
}
|
||||
|
||||
143
tests/feed-pagination.test.ts
Normal file
143
tests/feed-pagination.test.ts
Normal 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);
|
||||
});
|
||||
117
tests/feedless-subscribe.test.ts
Normal file
117
tests/feedless-subscribe.test.ts
Normal 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();
|
||||
});
|
||||
70
tests/itunes-feed-resolver.test.ts
Normal file
70
tests/itunes-feed-resolver.test.ts
Normal 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();
|
||||
});
|
||||
354
tests/podcastindex-fallback.test.ts
Normal file
354
tests/podcastindex-fallback.test.ts
Normal 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
267
tests/search-focus.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
46
tests/source-credentials.test.ts
Normal file
46
tests/source-credentials.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user