diff --git a/src/pages/Settings/SourceManager.tsx b/src/pages/Settings/SourceManager.tsx
index b237f35..f7a29c4 100644
--- a/src/pages/Settings/SourceManager.tsx
+++ b/src/pages/Settings/SourceManager.tsx
@@ -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(() => );
+ return;
+ }
+ feedStore.toggleSource(s.id);
+ },
});
}
@@ -151,3 +171,152 @@ function AddSourceForm() {
);
}
+
+/** 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(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 (
+
+
+ Free key + secret from https://podcastindex.org/. Used as a
+ fallback when other sources return fewer than 3 results.
+
+
+ API Key:
+ {
+ 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}
+ />
+
+
+ API Secret:
+ {
+ 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}
+ />
+
+ {(e) => {e()}}
+
+ Storing credentials...
+
+
+ [Enter] save · [Esc] cancel — keys stay stored when disabled.
+
+
+ );
+}
diff --git a/src/stores/feed.ts b/src/stores/feed.ts
index 48beb30..76ee61f 100644
--- a/src/stores/feed.ts
+++ b/src/stores/feed.ts
@@ -12,6 +12,7 @@ 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,
@@ -44,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 {
+ 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
@@ -355,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();
})();
@@ -437,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);
diff --git a/src/types/source.ts b/src/types/source.ts
index 4269d94..31c8fd4 100644
--- a/src/types/source.ts
+++ b/src/types/source.ts
@@ -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,
+ },
]
diff --git a/src/utils/search.ts b/src/utils/search.ts
index 4783b12..26d8bc7 100644
--- a/src/utils/search.ts
+++ b/src/utils/search.ts
@@ -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();
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();
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;
+
+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 => {
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 =>
+ 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 =>
+ searchSources(
+ query,
+ sourceIds,
+ sources,
+ searchEpisodesByType,
+ "episode",
+ options,
+ );
+
diff --git a/src/utils/source-credentials.ts b/src/utils/source-credentials.ts
new file mode 100644
index 0000000..aff4357
--- /dev/null
+++ b/src/utils/source-credentials.ts
@@ -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 {
+ 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 {
+ 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 {
+ if (source.credentialStorage === "plaintext") {
+ return source.apiKey && source.apiSecret
+ ? { apiKey: source.apiKey, apiSecret: source.apiSecret }
+ : null
+ }
+ return loadPodcastIndexCredentials()
+}
diff --git a/src/utils/source-searcher.ts b/src/utils/source-searcher.ts
index eeea64a..05ccb0a 100644
--- a/src/utils/source-searcher.ts
+++ b/src/utils/source-searcher.ts
@@ -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[]
@@ -28,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)
@@ -43,6 +67,165 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
return url.toString()
}
+/** 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
+ newestItemPubdate?: number
+}
+
+type PodcastIndexResponse = {
+ status?: string | boolean
+ feeds?: PodcastIndexResult[]
+}
+
+const sha1Hex = async (input: string): Promise => {
+ 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> => {
+ 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 => {
+ 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
@@ -74,7 +257,57 @@ export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Po
}
}
-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 => {
@@ -82,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
@@ -94,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 => {
+ switch (source.id) {
+ case "podcastindex":
+ return searchPodcastIndexSource(query, source)
+ default:
+ return searchItunesSource(query, source)
+ }
+}
+
+const searchItunesEpisodeSource = async (
+ query: string,
+ source: PodcastSource
+): Promise => {
+ 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 => {
+ 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
@@ -124,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 => {
+ if (source.type === SourceType.RSS) {
+ return searchRSSSource()
+ }
+ if (source.type === SourceType.CUSTOM) {
+ return searchCustomSource()
+ }
+ return searchEpisodeAPISource(query, source)
+}
diff --git a/tests/podcastindex-fallback.test.ts b/tests/podcastindex-fallback.test.ts
new file mode 100644
index 0000000..32bbfd2
--- /dev/null
+++ b/tests/podcastindex-fallback.test.ts
@@ -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;
+ 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;
+ 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");
+});
diff --git a/tests/source-credentials.test.ts b/tests/source-credentials.test.ts
new file mode 100644
index 0000000..7fb5da0
--- /dev/null
+++ b/tests/source-credentials.test.ts
@@ -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();
+});
diff --git a/tests/source-searcher.test.ts b/tests/source-searcher.test.ts
index 3e8d6ba..a2714f8 100644
--- a/tests/source-searcher.test.ts
+++ b/tests/source-searcher.test.ts
@@ -9,7 +9,12 @@
* search. This test pins the empty-result contract.
*/
import { test, expect } from "bun:test";
-import { searchSourceByType, mapItunesResult } 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";
@@ -99,3 +104,102 @@ test("iTunes results without a collection name stay dropped", () => {
);
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: "Guest: Jane Doe
Topic: AI.
",
+ 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([]);
+});