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).
241 lines
6.9 KiB
TypeScript
241 lines
6.9 KiB
TypeScript
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
|
import { parseRSSFeed } from "../api/rss-parser";
|
|
import { SourceType } from "../types/source";
|
|
import type { PodcastSource, SearchResult } from "../types/source";
|
|
|
|
type SearchCacheEntry = {
|
|
timestamp: number;
|
|
results: SearchResult[];
|
|
};
|
|
|
|
type SearchOptions = {
|
|
cacheTtl?: number;
|
|
};
|
|
|
|
const searchCache = new Map<string, SearchCacheEntry>();
|
|
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;
|
|
const timestamps =
|
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
|
|
|
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
|
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
|
|
if (waitMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
}
|
|
}
|
|
|
|
const updated =
|
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
|
updated.push(Date.now());
|
|
rateLimitState.set(sourceId, updated);
|
|
};
|
|
|
|
const buildCacheKey = (query: string, sourceIds: string[], prefix: string) => {
|
|
const keySources = [...sourceIds].sort().join(",");
|
|
return `${prefix}:${query.toLowerCase()}::${keySources}`;
|
|
};
|
|
|
|
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
|
Date.now() - entry.timestamp < ttl;
|
|
|
|
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.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);
|
|
}
|
|
}
|
|
return Array.from(map.values());
|
|
};
|
|
|
|
const FEED_URL_RE = /^https?:\/\/.+/i;
|
|
|
|
/**
|
|
* If the query is a direct RSS feed URL (useful for private feeds that aren't
|
|
* in public directories), fetch and parse it into a single search result.
|
|
* Returns an empty array when the query is not a URL so normal search proceeds.
|
|
*/
|
|
export const searchByFeedUrl = async (
|
|
query: string,
|
|
): Promise<SearchResult[]> => {
|
|
const trimmed = query.trim();
|
|
if (!FEED_URL_RE.test(trimmed)) return [];
|
|
|
|
try {
|
|
const response = await fetch(trimmed, {
|
|
headers: {
|
|
"Accept-Encoding": "identity",
|
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
},
|
|
});
|
|
if (!response.ok) return [];
|
|
|
|
const xml = await response.text();
|
|
const podcast = parseRSSFeed(xml, trimmed);
|
|
|
|
return [
|
|
{
|
|
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 },
|
|
score: 1,
|
|
},
|
|
];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
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 [];
|
|
|
|
const activeSources = sources.filter(
|
|
(source) => sourceIds.includes(source.id) && source.enabled,
|
|
);
|
|
|
|
if (activeSources.length === 0) {
|
|
// No enabled sources — surface a clear cause instead of returning empty,
|
|
// which otherwise looks indistinguishable from a network failure.
|
|
if (sourceIds.length === 0) {
|
|
throw new Error("No search sources are enabled");
|
|
}
|
|
throw new Error("No enabled sources match the selected search sources");
|
|
}
|
|
|
|
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
|
|
const cacheKey = buildCacheKey(
|
|
trimmed,
|
|
activeSources.map((s) => s.id),
|
|
cachePrefix,
|
|
);
|
|
const cached = searchCache.get(cacheKey);
|
|
if (cached && isCacheValid(cached, cacheTtl)) {
|
|
return cached.results;
|
|
}
|
|
|
|
const results: SearchResult[] = [];
|
|
const errors: Error[] = [];
|
|
|
|
await Promise.all(
|
|
activeSources.map(async (source) => {
|
|
try {
|
|
await throttleSource(source.id);
|
|
const sourceResults = await searcher(trimmed, source);
|
|
results.push(...sourceResults);
|
|
} catch (error) {
|
|
errors.push(error as Error);
|
|
}
|
|
}),
|
|
);
|
|
|
|
const deduped = dedupeResults(results);
|
|
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");
|
|
}
|
|
|
|
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
|
|
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,
|
|
);
|
|
|
|
|