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

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