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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user