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

@@ -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([]);
});