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:
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