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

@@ -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,
);