From ef9fc13aaa5b9a137af1f4bc04da06df66fca01d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 00:28:01 -0400 Subject: [PATCH] feat(settings): add Podcast Index fallback source with credential storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Podcast Index (api.podcastindex.org) ships as a disabled, key-less source and is only consulted as a fallback when primary search results are fewer than 3 — never on the hot path, never when disabled or credential-less. A failed fallback leaves primary results intact. Credentials are user-supplied: enabling the source pops a dialog that asks for the free key+secret, prefilled masked (first 3 chars + "...") when already stored; toggling off never clears them. Secrets prefer the macOS keychain (security CLI, encrypted at rest) with a plaintext config.json fallback when the keychain is unavailable; sources carry only a hasCredentials/credentialStorage marker, and legacy plaintext keys in existing configs are migrated on load. Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and Authorization = sha1(key + secret + date). Dead feeds are filtered, feed URLs are used directly, and episode-scope search is a no-op (no endpoint). --- src/pages/Settings/SourceManager.tsx | 175 ++++++++++++- src/stores/feed.ts | 68 ++++- src/types/source.ts | 56 ++++- src/utils/search.ts | 95 ++++++- src/utils/source-credentials.ts | 100 ++++++++ src/utils/source-searcher.ts | 310 ++++++++++++++++++++++- tests/podcastindex-fallback.test.ts | 354 +++++++++++++++++++++++++++ tests/source-credentials.test.ts | 46 ++++ tests/source-searcher.test.ts | 106 +++++++- 9 files changed, 1288 insertions(+), 22 deletions(-) create mode 100644 src/utils/source-credentials.ts create mode 100644 tests/podcastindex-fallback.test.ts create mode 100644 tests/source-credentials.test.ts 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([]); +});