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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { searchSourceByType } from "./source-searcher";
|
||||
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import { SourceType } from "../types/source";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
@@ -17,6 +17,12 @@ const rateLimitState = new Map<string, number[]>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||
const RATE_LIMIT_MAX_CALLS = 20;
|
||||
|
||||
/** Minimum results a primary search must return before the Podcast Index
|
||||
* fallback runs — the open directory is only consulted when Apple's came up
|
||||
* thin, exactly the case where it adds shows Apple lacks. */
|
||||
const FALLBACK_MIN_RESULTS = 3;
|
||||
const FALLBACK_SOURCE_ID = "podcastindex";
|
||||
|
||||
const throttleSource = async (sourceId: string) => {
|
||||
const now = Date.now();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||
@@ -36,9 +42,9 @@ const throttleSource = async (sourceId: string) => {
|
||||
rateLimitState.set(sourceId, updated);
|
||||
};
|
||||
|
||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||
const buildCacheKey = (query: string, sourceIds: string[], prefix: string) => {
|
||||
const keySources = [...sourceIds].sort().join(",");
|
||||
return `${query.toLowerCase()}::${keySources}`;
|
||||
return `${prefix}:${query.toLowerCase()}::${keySources}`;
|
||||
};
|
||||
|
||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
@@ -47,8 +53,12 @@ const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||
const map = new Map<string, SearchResult>();
|
||||
for (const result of results) {
|
||||
// Episodes dedupe on the episode id; shows on feedUrl/id/title. The two
|
||||
// scopes never mix within one result set, so keys can't collide.
|
||||
const key =
|
||||
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||
result.kind === "episode"
|
||||
? `episode:${result.episode.id}`
|
||||
: result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||
const existing = map.get(key);
|
||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||
map.set(key, result);
|
||||
@@ -87,6 +97,7 @@ export const searchByFeedUrl = async (
|
||||
sourceId: "direct-rss",
|
||||
sourceName: "RSS Feed",
|
||||
sourceType: SourceType.RSS,
|
||||
kind: "podcast",
|
||||
// parseRSSFeed marks feeds subscribed; a search result should start
|
||||
// unsubscribed so the store can flag it correctly if already added.
|
||||
podcast: { ...podcast, isSubscribed: false },
|
||||
@@ -98,11 +109,20 @@ export const searchByFeedUrl = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const searchPodcasts = async (
|
||||
type SourceSearcher = (
|
||||
query: string,
|
||||
source: PodcastSource,
|
||||
) => Promise<SearchResult[]>;
|
||||
|
||||
const searchSources = async (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
searcher: SourceSearcher,
|
||||
cachePrefix: string,
|
||||
options: SearchOptions = {},
|
||||
/** Optional source id consulted as a low-result fallback (show scope only). */
|
||||
fallbackSourceId?: string,
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
@@ -124,6 +144,7 @@ export const searchPodcasts = async (
|
||||
const cacheKey = buildCacheKey(
|
||||
trimmed,
|
||||
activeSources.map((s) => s.id),
|
||||
cachePrefix,
|
||||
);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||
@@ -137,7 +158,7 @@ export const searchPodcasts = async (
|
||||
activeSources.map(async (source) => {
|
||||
try {
|
||||
await throttleSource(source.id);
|
||||
const sourceResults = await searchSourceByType(trimmed, source);
|
||||
const sourceResults = await searcher(trimmed, source);
|
||||
results.push(...sourceResults);
|
||||
} catch (error) {
|
||||
errors.push(error as Error);
|
||||
@@ -146,7 +167,32 @@ export const searchPodcasts = async (
|
||||
);
|
||||
|
||||
const deduped = dedupeResults(results);
|
||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
let sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
// Low-result fallback: when the primary sources came back thin, consult
|
||||
// the fallback source — but only when it's enabled AND keyed (a key-less
|
||||
// default must never send requests) and it didn't already run as a primary
|
||||
// source above. A fallback failure never sinks the primary results.
|
||||
if (sorted.length < FALLBACK_MIN_RESULTS && fallbackSourceId) {
|
||||
const fallback = sources.find(
|
||||
(s) =>
|
||||
s.id === fallbackSourceId &&
|
||||
s.enabled &&
|
||||
s.hasCredentials === true &&
|
||||
!activeSources.includes(s),
|
||||
);
|
||||
if (fallback) {
|
||||
try {
|
||||
await throttleSource(fallback.id);
|
||||
const fallbackResults = await searcher(trimmed, fallback);
|
||||
sorted = dedupeResults([...sorted, ...fallbackResults]).sort(
|
||||
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length === 0 && errors.length > 0) {
|
||||
throw new Error("Search failed for all sources");
|
||||
@@ -156,4 +202,39 @@ export const searchPodcasts = async (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const searchPodcasts = (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {},
|
||||
): Promise<SearchResult[]> =>
|
||||
searchSources(
|
||||
query,
|
||||
sourceIds,
|
||||
sources,
|
||||
searchSourceByType,
|
||||
"show",
|
||||
options,
|
||||
FALLBACK_SOURCE_ID,
|
||||
);
|
||||
|
||||
/** Episode-scope search: find individual episodes (e.g. a guest appearing
|
||||
* across shows). Shares the source guard, rate limiting, and cache with
|
||||
* searchPodcasts; the cache key is scoped separately so the two result
|
||||
* kinds never collide for the same query. */
|
||||
export const searchEpisodes = (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {},
|
||||
): Promise<SearchResult[]> =>
|
||||
searchSources(
|
||||
query,
|
||||
sourceIds,
|
||||
sources,
|
||||
searchEpisodesByType,
|
||||
"episode",
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
|
||||
100
src/utils/source-credentials.ts
Normal file
100
src/utils/source-credentials.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Credential storage for keyed podcast sources.
|
||||
*
|
||||
* Preferred storage is the macOS keychain (encrypted at rest by the OS),
|
||||
* written through the `security` CLI — no native dependencies. When the
|
||||
* keychain is unavailable (non-macOS, locked, sandboxed) credentials fall
|
||||
* back to plaintext on the source itself (config.json) so the source still
|
||||
* works; `credentialStorage` on the source records which backend was used.
|
||||
*
|
||||
* Credentials are never presented in full — the UI always masks them (first
|
||||
* 3 chars + "..."). The keychain password is passed as an argv value to
|
||||
* `add-generic-password` (standard practice for CLI-driven keychain writes;
|
||||
* the item lands in the login keychain immediately).
|
||||
*/
|
||||
|
||||
import type { PodcastSource } from "../types/source"
|
||||
|
||||
const KEYCHAIN_SERVICE = "podtui"
|
||||
const KEYCHAIN_ACCOUNT = "podcastindex"
|
||||
|
||||
export type Credentials = {
|
||||
apiKey: string
|
||||
apiSecret: string
|
||||
}
|
||||
|
||||
/** Run a `security` subcommand; resolves with exit status + stdout. */
|
||||
async function runSecurity(
|
||||
args: string[],
|
||||
): Promise<{ ok: boolean; stdout: string }> {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["security", ...args],
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
return { ok: exitCode === 0, stdout }
|
||||
} catch {
|
||||
return { ok: false, stdout: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Store Podcast Index credentials in the macOS keychain. True on success. */
|
||||
export async function savePodcastIndexCredentials(
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
): Promise<boolean> {
|
||||
const payload = JSON.stringify({ apiKey, apiSecret })
|
||||
const { ok } = await runSecurity([
|
||||
"add-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
payload,
|
||||
"-U",
|
||||
])
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Read Podcast Index credentials from the macOS keychain. Null when absent
|
||||
* or unreadable (non-macOS, item deleted, keychain locked). */
|
||||
export async function loadPodcastIndexCredentials(): Promise<Credentials | null> {
|
||||
const { ok, stdout } = await runSecurity([
|
||||
"find-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
])
|
||||
if (!ok) return null
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim()) as Credentials
|
||||
if (!parsed.apiKey || !parsed.apiSecret) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a source's stored credentials: its plaintext fields when saved
|
||||
* with the plaintext fallback, else the macOS keychain. Null when the
|
||||
* source has no usable credentials. */
|
||||
export async function resolveSourceCredentials(
|
||||
source: PodcastSource,
|
||||
): Promise<Credentials | null> {
|
||||
if (source.credentialStorage === "plaintext") {
|
||||
return source.apiKey && source.apiSecret
|
||||
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
|
||||
: null
|
||||
}
|
||||
return loadPodcastIndexCredentials()
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { SourceType } from "../types/source"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
import { detectContentType, ContentType } from "../utils/rss-content-detector"
|
||||
import { htmlToText } from "../utils/html-to-text"
|
||||
import { resolveSourceCredentials } from "../utils/source-credentials"
|
||||
|
||||
type SearcherResult = SearchResult[]
|
||||
|
||||
@@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
if (detectContentType(decoded) === ContentType.HTML) {
|
||||
return htmlToText(decoded)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an iTunes episode record to a search result: the episode itself plus its
|
||||
* parent show (as a Podcast) so subscribing works exactly like a show result.
|
||||
* Returns null when the record is missing a track or collection name.
|
||||
*/
|
||||
export const mapItunesEpisodeResult = (
|
||||
result: ItunesEpisodeResult,
|
||||
source: PodcastSource,
|
||||
): { podcast: Podcast; episode: Episode } | null => {
|
||||
if (!result.trackName || !result.collectionName) return null
|
||||
|
||||
const podcast = mapItunesResult(result, source)
|
||||
if (!podcast) return null
|
||||
|
||||
const episode: Episode = {
|
||||
id: result.trackId
|
||||
? `itunes-ep-${result.trackId}`
|
||||
: `itunes-ep-${slugify(result.trackName)}`,
|
||||
podcastId: podcast.id,
|
||||
title: result.trackName,
|
||||
description: cleanDescription(result.description ?? ""),
|
||||
audioUrl: result.episodeUrl ?? "",
|
||||
duration: result.trackTimeMillis
|
||||
? Math.round(result.trackTimeMillis / 1000)
|
||||
: 0,
|
||||
pubDate: result.releaseDate ? new Date(result.releaseDate) : new Date(),
|
||||
}
|
||||
|
||||
return { podcast, episode }
|
||||
}
|
||||
|
||||
const searchItunesSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
354
tests/podcastindex-fallback.test.ts
Normal file
354
tests/podcastindex-fallback.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Podcast Index fallback tests.
|
||||
*
|
||||
* The Podcast Index source ships disabled and key-less. It must only be
|
||||
* consulted as a low-result fallback (fewer than 3 primary results), only
|
||||
* when enabled AND its credentials are stored (hasCredentials), and never
|
||||
* twice when also selected as a primary source. Credentials prefer the OS
|
||||
* keychain (encrypted at rest); when the keychain is unavailable they fall
|
||||
* back to plaintext on the source (config.json). Auth follows the documented
|
||||
* scheme: X-Auth-Key, X-Auth-Date (unix epoch), Authorization =
|
||||
* sha1(key + secret + date).
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { searchPodcasts } from "../src/utils/search";
|
||||
import {
|
||||
searchSourceByType,
|
||||
searchEpisodesByType,
|
||||
mapPodcastIndexResult,
|
||||
} from "../src/utils/source-searcher";
|
||||
import { SourceType } from "../src/types/source";
|
||||
import type { PodcastSource } from "../src/types/source";
|
||||
|
||||
// The searcher pulls credentials from the credential-storage module;
|
||||
// mock.module is hoisted above the imports, so this stub lands before
|
||||
// source-searcher loads. resolveSourceCredentials mirrors the real resolver
|
||||
// (plaintext branch vs keychain branch) against the mutable keychainState.
|
||||
const keychainState: {
|
||||
credentials: { apiKey: string; apiSecret: string } | null;
|
||||
} = {
|
||||
credentials: { apiKey: "TESTKEY123", apiSecret: "TESTSECRET456" },
|
||||
};
|
||||
|
||||
mock.module("../src/utils/source-credentials", () => ({
|
||||
savePodcastIndexCredentials: async () => true,
|
||||
loadPodcastIndexCredentials: async () => keychainState.credentials,
|
||||
resolveSourceCredentials: async (source: PodcastSource) =>
|
||||
source.credentialStorage === "plaintext"
|
||||
? source.apiKey && source.apiSecret
|
||||
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
|
||||
: null
|
||||
: keychainState.credentials,
|
||||
}));
|
||||
|
||||
const sha1 = (input: string): string =>
|
||||
Bun.CryptoHasher.hash("sha1", input, "hex") as string;
|
||||
|
||||
const itunesSource: PodcastSource = {
|
||||
id: "itunes",
|
||||
name: "Apple Podcasts",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://itunes.apple.com/search",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const keyedPodcastIndex: PodcastSource = {
|
||||
id: "podcastindex",
|
||||
name: "Podcast Index",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||
enabled: true,
|
||||
hasCredentials: true,
|
||||
};
|
||||
|
||||
const PI_URL = "https://api.podcastindex.org/api/1.0/search/byterm";
|
||||
const ITUNES_URL = "https://itunes.apple.com/search";
|
||||
|
||||
const jsonResponse = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const itunesResults = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
collectionId: i + 1,
|
||||
collectionName: `Show ${i + 1}`,
|
||||
feedUrl: `https://example.com/feed${i + 1}.xml`,
|
||||
}));
|
||||
|
||||
/** Route iTunes vs Podcast Index fetches; records call URLs. */
|
||||
const routeFetch = (
|
||||
calls: string[],
|
||||
opts: { itunesCount?: number; piFeeds?: unknown[]; piStatus?: number } = {},
|
||||
) =>
|
||||
mock(async (url: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
if (u.startsWith(ITUNES_URL)) {
|
||||
const count = opts.itunesCount ?? 0;
|
||||
return jsonResponse({ resultCount: count, results: itunesResults(count) });
|
||||
}
|
||||
if (u.startsWith(PI_URL)) {
|
||||
if (opts.piStatus && opts.piStatus >= 400) {
|
||||
return new Response("nope", { status: opts.piStatus });
|
||||
}
|
||||
const feeds = opts.piFeeds ?? [];
|
||||
return jsonResponse({ status: "true", feeds, count: feeds.length });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ── result mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
test("Podcast Index results map to podcasts with the feed URL direct", () => {
|
||||
const mapped = mapPodcastIndexResult(
|
||||
{
|
||||
id: 42,
|
||||
title: "Fallback Show",
|
||||
url: "https://example.com/feed2.xml",
|
||||
author: "Open Directory",
|
||||
image: "https://example.com/art.jpg",
|
||||
language: "en",
|
||||
episodeCount: 10,
|
||||
lastUpdateTime: 1600000000,
|
||||
categories: { "104": "Technology", "105": "News" },
|
||||
},
|
||||
keyedPodcastIndex,
|
||||
);
|
||||
expect(mapped).not.toBeNull();
|
||||
expect(mapped!.id).toBe("podcastindex-42");
|
||||
expect(mapped!.title).toBe("Fallback Show");
|
||||
// PI is feed-first: the feed URL is present, no delisted-show stub step.
|
||||
expect(mapped!.feedUrl).toBe("https://example.com/feed2.xml");
|
||||
expect(mapped!.directoryUrl).toBeUndefined();
|
||||
expect(mapped!.categories).toEqual(["Technology", "News"]);
|
||||
expect(mapped!.lastUpdated.toISOString()).toBe("2020-09-13T12:26:40.000Z");
|
||||
expect(mapped!.isSubscribed).toBe(false);
|
||||
});
|
||||
|
||||
test("Podcast Index results without a title or feed URL stay dropped", () => {
|
||||
expect(mapPodcastIndexResult({ id: 1 }, keyedPodcastIndex)).toBeNull();
|
||||
expect(
|
||||
mapPodcastIndexResult({ title: "No Feed" }, keyedPodcastIndex),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ── auth scheme ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("Podcast Index auth uses sha1(key+secret+date) from keychain credentials", async () => {
|
||||
let captured: RequestInit | undefined;
|
||||
globalThis.fetch = mock(
|
||||
async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).startsWith(PI_URL)) {
|
||||
captured = init;
|
||||
return jsonResponse({
|
||||
status: "true",
|
||||
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ resultCount: 0, results: [] });
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await searchSourceByType("hello", keyedPodcastIndex);
|
||||
|
||||
const headers = captured?.headers as Record<string, string>;
|
||||
expect(headers["X-Auth-Key"]).toBe("TESTKEY123");
|
||||
expect(headers["User-Agent"]).toBe("PodTUI/1.0");
|
||||
expect(headers["X-Auth-Date"]).toMatch(/^\d{10}$/);
|
||||
expect(headers["Authorization"]).toBe(
|
||||
sha1(`TESTKEY123TESTSECRET456${headers["X-Auth-Date"]}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("plaintext-stored credentials drive the search without the keychain", async () => {
|
||||
keychainState.credentials = null;
|
||||
let captured: RequestInit | undefined;
|
||||
globalThis.fetch = mock(
|
||||
async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).startsWith(PI_URL)) {
|
||||
captured = init;
|
||||
return jsonResponse({
|
||||
status: "true",
|
||||
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ resultCount: 0, results: [] });
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const plaintext: PodcastSource = {
|
||||
...keyedPodcastIndex,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
};
|
||||
try {
|
||||
await searchSourceByType("hello", plaintext);
|
||||
} finally {
|
||||
keychainState.credentials = {
|
||||
apiKey: "TESTKEY123",
|
||||
apiSecret: "TESTSECRET456",
|
||||
};
|
||||
}
|
||||
|
||||
const headers = captured?.headers as Record<string, string>;
|
||||
expect(headers["X-Auth-Key"]).toBe("PLAINTEXTKEY");
|
||||
expect(headers["Authorization"]).toBe(
|
||||
sha1(`PLAINTEXTKEYPLAINTEXTSECRET${headers["X-Auth-Date"]}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("missing keychain credentials fail with a setup message, not a request", async () => {
|
||||
keychainState.credentials = null;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 0 });
|
||||
try {
|
||||
await expect(searchSourceByType("hello", keyedPodcastIndex)).rejects.toThrow(
|
||||
/credentials are missing/,
|
||||
);
|
||||
} finally {
|
||||
keychainState.credentials = {
|
||||
apiKey: "TESTKEY123",
|
||||
apiSecret: "TESTSECRET456",
|
||||
};
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("Podcast Index has no episode-scope search backend", async () => {
|
||||
const results = await searchEpisodesByType("hello", keyedPodcastIndex);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
// ── fallback behavior ────────────────────────────────────────────────────────
|
||||
|
||||
test("thin primary results trigger the keyed Podcast Index fallback", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 1,
|
||||
piFeeds: [
|
||||
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
|
||||
],
|
||||
});
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-thin",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(true);
|
||||
const pi = results.find((r) => r.sourceId === "podcastindex");
|
||||
expect(pi?.sourceName).toBe("Podcast Index");
|
||||
expect(pi?.podcast.title).toBe("Fallback Show");
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("fallback is skipped when primary results meet the threshold", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 5 });
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-full",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
expect(results.length).toBe(5);
|
||||
});
|
||||
|
||||
test("a disabled Podcast Index source is never consulted", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
|
||||
const disabled = { ...keyedPodcastIndex, enabled: false };
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-disabled",
|
||||
["itunes"],
|
||||
[itunesSource, disabled],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
});
|
||||
|
||||
test("a credential-less Podcast Index source never sends requests", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
|
||||
const keyless = { ...keyedPodcastIndex, hasCredentials: false };
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-keyless",
|
||||
["itunes"],
|
||||
[itunesSource, keyless],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
});
|
||||
|
||||
test("Podcast Index selected as a primary source is fetched once, not twice", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 1,
|
||||
piFeeds: [
|
||||
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
|
||||
],
|
||||
});
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-both",
|
||||
["itunes", "podcastindex"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.filter((u) => u.startsWith(PI_URL)).length).toBe(1);
|
||||
});
|
||||
|
||||
test("a failing fallback leaves the primary results intact", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1, piStatus: 500 });
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-pi-fail",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].sourceId).toBe("itunes");
|
||||
});
|
||||
|
||||
test("dead Podcast Index feeds are filtered out", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 0,
|
||||
piFeeds: [
|
||||
{ id: 1, title: "Live Show", url: "https://example.com/live.xml" },
|
||||
{
|
||||
id: 2,
|
||||
title: "Dead Show",
|
||||
url: "https://example.com/dead.xml",
|
||||
dead: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-dead",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
const pi = results.filter((r) => r.sourceId === "podcastindex");
|
||||
expect(pi.length).toBe(1);
|
||||
expect(pi[0].podcast.title).toBe("Live Show");
|
||||
});
|
||||
46
tests/source-credentials.test.ts
Normal file
46
tests/source-credentials.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Credential resolution tests against the real module (no mocks).
|
||||
*
|
||||
* Only the plaintext branch is exercised: the keychain branch spawns the
|
||||
* `security` CLI and would depend on the host machine's keychain state
|
||||
* (the keychain-backed pipeline is covered in podcastindex-fallback.test.ts
|
||||
* with a stubbed module). The plaintext branch must never touch the
|
||||
* keychain — it is the fallback that keeps the source working on machines
|
||||
* without a usable macOS keychain.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { resolveSourceCredentials } from "../src/utils/source-credentials";
|
||||
import { SourceType } from "../src/types/source";
|
||||
import type { PodcastSource } from "../src/types/source";
|
||||
|
||||
const base: PodcastSource = {
|
||||
id: "podcastindex",
|
||||
name: "Podcast Index",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||
enabled: true,
|
||||
hasCredentials: true,
|
||||
};
|
||||
|
||||
test("plaintext-storage sources resolve their own fields, no keychain call", async () => {
|
||||
const source: PodcastSource = {
|
||||
...base,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
};
|
||||
expect(await resolveSourceCredentials(source)).toEqual({
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
});
|
||||
});
|
||||
|
||||
test("plaintext-storage sources with empty fields resolve to null", async () => {
|
||||
const source: PodcastSource = {
|
||||
...base,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: undefined,
|
||||
apiSecret: undefined,
|
||||
};
|
||||
expect(await resolveSourceCredentials(source)).toBeNull();
|
||||
});
|
||||
@@ -9,7 +9,12 @@
|
||||
* search. This test pins the empty-result contract.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { searchSourceByType, 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: "<p>Guest: <strong>Jane Doe</strong></p><p>Topic: AI.</p>",
|
||||
feedUrl: "https://example.com/feed.xml",
|
||||
},
|
||||
itunesSource,
|
||||
);
|
||||
expect(mapped).not.toBeNull();
|
||||
const desc = mapped!.episode.description;
|
||||
expect(desc).toContain("Jane Doe");
|
||||
expect(desc).not.toContain("<");
|
||||
expect(desc).not.toContain(">");
|
||||
});
|
||||
|
||||
test("episode results without a track name stay dropped", () => {
|
||||
const dropped = mapItunesEpisodeResult(
|
||||
{ collectionId: 1, collectionName: "Some Show" },
|
||||
itunesSource,
|
||||
);
|
||||
expect(dropped).toBeNull();
|
||||
});
|
||||
|
||||
test("episode results without a collection name stay dropped", () => {
|
||||
const dropped = mapItunesEpisodeResult(
|
||||
{ trackId: 1, trackName: "Some Episode" },
|
||||
itunesSource,
|
||||
);
|
||||
expect(dropped).toBeNull();
|
||||
});
|
||||
|
||||
test("episode results of delisted shows keep a directory fallback on the show", () => {
|
||||
const mapped = mapItunesEpisodeResult(
|
||||
{
|
||||
trackId: 2,
|
||||
trackName: "An episode",
|
||||
collectionId: 1047335260,
|
||||
collectionName: "The Ben Shapiro Show",
|
||||
artistName: "The Daily Wire",
|
||||
feedUrl: null,
|
||||
collectionViewUrl:
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
|
||||
},
|
||||
itunesSource,
|
||||
);
|
||||
expect(mapped).not.toBeNull();
|
||||
expect(mapped!.podcast.feedUrl).toBe("");
|
||||
expect(mapped!.podcast.directoryUrl).toBe(
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
|
||||
);
|
||||
});
|
||||
|
||||
test("RSS and custom sources return no episode search results either", async () => {
|
||||
expect(await searchEpisodesByType("sam altman", rssSource)).toEqual([]);
|
||||
expect(await searchEpisodesByType("sam altman", customSource)).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user