feat(settings): add Podcast Index fallback source with credential storage

Podcast Index (api.podcastindex.org) ships as a disabled, key-less source
and is only consulted as a fallback when primary search results are fewer
than 3 — never on the hot path, never when disabled or credential-less.
A failed fallback leaves primary results intact.

Credentials are user-supplied: enabling the source pops a dialog that
asks for the free key+secret, prefilled masked (first 3 chars + "...")
when already stored; toggling off never clears them. Secrets prefer the
macOS keychain (security CLI, encrypted at rest) with a plaintext
config.json fallback when the keychain is unavailable; sources carry only
a hasCredentials/credentialStorage marker, and legacy plaintext keys in
existing configs are migrated on load.

Auth follows the documented scheme: X-Auth-Key, X-Auth-Date (epoch) and
Authorization = sha1(key + secret + date). Dead feeds are filtered, feed
URLs are used directly, and episode-scope search is a no-op (no endpoint).
This commit is contained in:
2026-08-11 00:28:01 -04:00
parent 0b0637b9dc
commit ef9fc13aaa
9 changed files with 1288 additions and 22 deletions

View File

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

View File

@@ -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<PodcastSource[]> {
let changed = false;
const migrated: PodcastSource[] = [];
for (const source of sources) {
if (
source.id === "podcastindex" &&
source.apiKey &&
source.apiSecret &&
!source.hasCredentials
) {
const ok = await savePodcastIndexCredentials(
source.apiKey,
source.apiSecret,
);
if (ok) {
migrated.push({
...source,
apiKey: undefined,
apiSecret: undefined,
hasCredentials: true,
credentialStorage: "keychain",
});
} else {
migrated.push({
...source,
hasCredentials: true,
credentialStorage: "plaintext",
});
}
changed = true;
continue;
}
migrated.push(source);
}
return changed ? migrated : sources;
}
/** True when two episode lists hold the same episodes (id-set equality,
* order-insensitive). Refreshes compare fetched content against this so an
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
@@ -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);

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
import type { Podcast } from "../types/podcast"
import type { Episode } from "../types/episode"
import { SourceType } from "../types/source"
import type { PodcastSource, SearchResult } from "../types/source"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
import { resolveSourceCredentials } from "../utils/source-credentials"
type SearcherResult = SearchResult[]
@@ -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<string, string>
newestItemPubdate?: number
}
type PodcastIndexResponse = {
status?: string | boolean
feeds?: PodcastIndexResult[]
}
const sha1Hex = async (input: string): Promise<string> => {
const data = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest("SHA-1", data)
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
/** Podcast Index auth is header-based: X-Auth-Key + X-Auth-Date (unix epoch
* seconds) + Authorization = sha1(key + secret + epoch). No query params.
* Credentials resolve from the source's storage backend: the OS keychain
* (encrypted at rest) by default, or the source's plaintext fields when the
* keychain was unavailable at save time. */
const buildPodcastIndexHeaders = async (
source: PodcastSource,
): Promise<Record<string, string>> => {
const credentials = await resolveSourceCredentials(source)
const key = credentials?.apiKey
const secret = credentials?.apiSecret
if (!key || !secret) {
throw new Error(
`${source.name} credentials are missing — enable the source in Settings → Sources to enter them`,
)
}
const epoch = Math.floor(Date.now() / 1000).toString()
const signature = await sha1Hex(key + secret + epoch)
return {
"User-Agent": "PodTUI/1.0",
"X-Auth-Key": key,
"X-Auth-Date": epoch,
Authorization: signature,
}
}
const buildPodcastIndexUrl = (query: string, source: PodcastSource) => {
const url = new URL(source.baseUrl)
url.searchParams.set("q", query.trim())
url.searchParams.set("max", "25")
return url.toString()
}
export const mapPodcastIndexResult = (
result: PodcastIndexResult,
source: PodcastSource,
): Podcast | null => {
if (!result.title || !result.url) return null
const id = result.id
? `podcastindex-${result.id}`
: `podcastindex-${slugify(result.title)}`
const descriptionParts = [result.title]
if (result.author) descriptionParts.push(`by ${result.author}`)
if (result.episodeCount !== undefined)
descriptionParts.push(`${result.episodeCount} episodes`)
return {
id,
title: result.title,
description: descriptionParts.join(" • "),
feedUrl: result.url,
author: result.author,
categories: result.categories
? Object.values(result.categories)
: undefined,
coverUrl: result.image || result.artwork,
language: result.language,
websiteUrl: result.link,
lastUpdated: result.lastUpdateTime
? new Date(result.lastUpdateTime * 1000)
: new Date(),
isSubscribed: false,
}
}
const searchPodcastIndexSource = async (
query: string,
source: PodcastSource,
): Promise<SearcherResult> => {
const headers = await buildPodcastIndexHeaders(source)
const response = await fetch(buildPodcastIndexUrl(query, source), {
headers,
})
if (!response.ok) {
throw new Error(`${source.name} search failed: ${response.status}`)
}
const data = (await response.json()) as PodcastIndexResponse
const results = (data.feeds ?? [])
.filter((item) => !item.dead)
.map((item) => mapPodcastIndexResult(item, source))
.filter((item): item is Podcast => Boolean(item))
return results.map((podcast, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "podcast" as const,
podcast,
score: 1 - index * 0.02,
}))
}
export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
if (!result.collectionName) return null
@@ -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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
if (detectContentType(decoded) === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Map an iTunes episode record to a search result: the episode itself plus its
* parent show (as a Podcast) so subscribing works exactly like a show result.
* Returns null when the record is missing a track or collection name.
*/
export const mapItunesEpisodeResult = (
result: ItunesEpisodeResult,
source: PodcastSource,
): { podcast: Podcast; episode: Episode } | null => {
if (!result.trackName || !result.collectionName) return null
const podcast = mapItunesResult(result, source)
if (!podcast) return null
const episode: Episode = {
id: result.trackId
? `itunes-ep-${result.trackId}`
: `itunes-ep-${slugify(result.trackName)}`,
podcastId: podcast.id,
title: result.trackName,
description: cleanDescription(result.description ?? ""),
audioUrl: result.episodeUrl ?? "",
duration: result.trackTimeMillis
? Math.round(result.trackTimeMillis / 1000)
: 0,
pubDate: result.releaseDate ? new Date(result.releaseDate) : new Date(),
}
return { podcast, episode }
}
const searchItunesSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -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<SearcherResult> => {
switch (source.id) {
case "podcastindex":
return searchPodcastIndexSource(query, source)
default:
return searchItunesSource(query, source)
}
}
const searchItunesEpisodeSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
const url = buildItunesEpisodeUrl(query, source)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${source.name} episode search failed: ${response.status}`)
}
const data = (await response.json()) as { results: ItunesEpisodeResult[] }
const results = data.results
.map((item) => mapItunesEpisodeResult(item, source))
.filter(
(item): item is { podcast: Podcast; episode: Episode } => Boolean(item),
)
return results.map(({ podcast, episode }, index) => ({
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
kind: "episode" as const,
podcast,
episode,
score: 1 - index * 0.02,
}))
}
/** Episode-scope API dispatch: only iTunes supports episode-by-term text
* search; Podcast Index has no such endpoint (its episode search is
* by-person only), so it contributes nothing to episode scope. */
const searchEpisodeAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.id === "podcastindex") return []
return searchItunesEpisodeSource(query, source)
}
/**
* RSS-type sources have no directory search backend: a feed URL identifies one
* show, and no API exists to search across "the RSS directory". Return no
@@ -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<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource()
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource()
}
return searchEpisodeAPISource(query, source)
}