refactor(feed): single RSS client closes search timeout gap
fetchFeedXml owns headers + 20s timeout; both hand-rolled fetches in feed.ts (fetchEpisodes, load-more cold path) and searchByFeedUrl route through it — direct-URL search hung indefinitely before.
This commit is contained in:
@@ -7,25 +7,31 @@ import { createSignal } from "solid-js";
|
|||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
||||||
import { FeedVisibility } from "../types/feed";
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
|
import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client";
|
||||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
|
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||||
import {
|
import {
|
||||||
episodeSignature,
|
episodeKeepFn,
|
||||||
mergeEpisodesBounded,
|
episodeTs,
|
||||||
} from "../utils/episode-merge";
|
dateFetchMoreCutoff,
|
||||||
|
dateBandCount,
|
||||||
|
sameRefreshWindow,
|
||||||
|
} from "../utils/episode-windows";
|
||||||
|
import { createSourceRegistry } from "../utils/source-registry";
|
||||||
|
import { createPersistScheduler } from "./persist";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
episodeInWindow,
|
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
|
loadSourcesFromFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useActivityStore } from "./activity";
|
import { useActivityStore } from "./activity";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
@@ -33,25 +39,12 @@ import { useAppStore } from "./app";
|
|||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
||||||
* by FETCH_MORE_WINDOW_DAYS instead). */
|
* by episode-windows' fetch-more band instead). */
|
||||||
const MAX_EPISODES_REFRESH = 50;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
/** Floor on the visible episode window for a subscribed show: at least this
|
|
||||||
* many most-recent episodes always load, regardless of a stricter count or
|
|
||||||
* date cache bound. Overridden by episodeKeepFn. */
|
|
||||||
const MIN_EPISODES_PER_SHOW = 5;
|
|
||||||
|
|
||||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
|
||||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
|
||||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
|
||||||
|
|
||||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
|
||||||
* the background refresh loop. */
|
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
|
||||||
|
|
||||||
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
||||||
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
||||||
* batch. */
|
* batch. */
|
||||||
@@ -127,73 +120,21 @@ const fullEpisodeCache = new Map<string, Episode[]>();
|
|||||||
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||||
const episodeLoadCount = new Map<string, number>();
|
const episodeLoadCount = new Map<string, number>();
|
||||||
|
|
||||||
/** Read the episode cache bound from preferences: a closure that decides
|
/** Write closure for the persist scheduler — reads the live feed signal
|
||||||
* whether the episode at `index` (0 = newest, after sort) is kept. The five
|
* (wired by createFeedStore) so a flush always lands the latest value. */
|
||||||
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
|
let readFeeds: () => Feed[] = () => [];
|
||||||
* overriding a stricter count or date bound so every show surfaces at least
|
|
||||||
* five episodes. */
|
|
||||||
function episodeKeepFn(prefs: {
|
|
||||||
episodeCacheMode: "date" | "count";
|
|
||||||
episodeCacheCount: number;
|
|
||||||
episodeCacheDays: number;
|
|
||||||
}): (ep: Episode, index: number) => boolean {
|
|
||||||
const now = new Date();
|
|
||||||
if (prefs.episodeCacheMode === "count") {
|
|
||||||
const count = Math.max(1, prefs.episodeCacheCount);
|
|
||||||
return (_ep: Episode, index: number) =>
|
|
||||||
index < Math.max(count, MIN_EPISODES_PER_SHOW);
|
|
||||||
}
|
|
||||||
const days = Math.max(1, prefs.episodeCacheDays);
|
|
||||||
return (ep: Episode, index: number) =>
|
|
||||||
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, now, days);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
/** Shared trailing-edge debouncer for config.json writes ("feeds" domain);
|
||||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
* sources persist immediately instead. */
|
||||||
const epTs = (ep: Episode): number => {
|
const persistScheduler = createPersistScheduler(() => {
|
||||||
const t = ep.pubDate?.getTime();
|
|
||||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
|
||||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
|
||||||
* the cache window), the band anchors at the cache-window edge (now minus
|
|
||||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
|
||||||
* episodes just because the button is pressed. */
|
|
||||||
const dateFetchMoreCutoff = (
|
|
||||||
cached: Episode[],
|
|
||||||
loaded: number,
|
|
||||||
windowDays: number,
|
|
||||||
): number => {
|
|
||||||
if (loaded > 0) {
|
|
||||||
const t = epTs(cached[loaded - 1]);
|
|
||||||
if (Number.isFinite(t)) {
|
|
||||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
|
||||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
|
||||||
return (
|
|
||||||
Date.now() -
|
|
||||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
|
||||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget). */
|
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
|
||||||
const prefs = useAppStore().state().preferences;
|
const prefs = useAppStore().state().preferences;
|
||||||
const days =
|
saveFeedsToFile(
|
||||||
|
readFeeds(),
|
||||||
prefs.episodeCacheMode === "date"
|
prefs.episodeCacheMode === "date"
|
||||||
? Math.max(1, prefs.episodeCacheDays)
|
? Math.max(1, prefs.episodeCacheDays)
|
||||||
: undefined;
|
: undefined,
|
||||||
saveFeedsToFile(feeds, days);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
|
||||||
saveSourcesToFile(sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
||||||
* keychain, marking the source hasCredentials and stripping the plaintext.
|
* keychain, marking the source hasCredentials and stripping the plaintext.
|
||||||
@@ -239,39 +180,10 @@ async function migratePlaintextCredentials(
|
|||||||
return changed ? migrated : sources;
|
return changed ? migrated : sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the freshly fetched window matches the corresponding PREFIX of
|
|
||||||
* the existing episode list (id-set equality, order-insensitive). With
|
|
||||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
|
||||||
* fetched window, so unchanged-detection must compare the fetched window
|
|
||||||
* against the existing list's prefix — comparing full lists would bump
|
|
||||||
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
|
||||||
* one-time positional-id migration, or a feed that rotates enclosure URLs)
|
|
||||||
* the id sets differ for the SAME content, so a content-signature
|
|
||||||
* comparison decides: an unchanged feed stays unchanged. */
|
|
||||||
export function sameRefreshWindow(
|
|
||||||
existing: Episode[],
|
|
||||||
fetched: Episode[],
|
|
||||||
): boolean {
|
|
||||||
if (fetched.length === 0) return true;
|
|
||||||
const prefix = existing.slice(0, fetched.length);
|
|
||||||
const ids = new Set(prefix.map((e) => e.id));
|
|
||||||
if (fetched.every((e) => ids.has(e.id))) return true;
|
|
||||||
if (prefix.length !== fetched.length) return false;
|
|
||||||
const signatures = new Set(prefix.map(episodeSignature));
|
|
||||||
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
readFeeds = () => feeds();
|
||||||
...DEFAULT_SOURCES,
|
const registry = createSourceRegistry(DEFAULT_SOURCES);
|
||||||
]);
|
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
|
||||||
visibility: "all",
|
|
||||||
sortBy: "updated" as FeedSortField,
|
|
||||||
sortDirection: "desc",
|
|
||||||
});
|
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
||||||
@@ -280,93 +192,26 @@ function createFeedStore() {
|
|||||||
* dump deep history (see getAllEpisodesChronological). */
|
* dump deep history (see getAllEpisodesChronological). */
|
||||||
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
||||||
|
|
||||||
// ── Debounced persistence ───────────────────────────────────────────────
|
|
||||||
/** Trailing-edge debounce window for config.json writes. */
|
|
||||||
const SAVE_DEBOUNCE_MS = 250;
|
|
||||||
/** True when a save is scheduled but has not flushed yet. */
|
|
||||||
let savePending = false;
|
|
||||||
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/** Schedule a config.json write (trailing edge) — rapid state changes
|
|
||||||
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
|
|
||||||
* collapse into one final write instead of one file rewrite per step. */
|
|
||||||
const scheduleSaveFeeds = (): void => {
|
const scheduleSaveFeeds = (): void => {
|
||||||
savePending = true;
|
persistScheduler.schedule("feeds");
|
||||||
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
|
|
||||||
pendingSaveTimer = setTimeout(() => {
|
|
||||||
pendingSaveTimer = null;
|
|
||||||
flushPendingSave();
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Persist immediately when anything is dirty; exported for tests and
|
|
||||||
* quit hooks. Cancels a pending debounced save — the state it would
|
|
||||||
* have written is already reflected in feeds(), so writing now is
|
|
||||||
* strictly more current. */
|
|
||||||
const flushPendingSave = (): void => {
|
const flushPendingSave = (): void => {
|
||||||
if (pendingSaveTimer) {
|
persistScheduler.flush("feeds");
|
||||||
clearTimeout(pendingSaveTimer);
|
|
||||||
pendingSaveTimer = null;
|
|
||||||
}
|
|
||||||
if (!savePending) return;
|
|
||||||
savePending = false;
|
|
||||||
saveFeeds(feeds());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
// The filter signal is write-only (no caller mutates it), so every
|
||||||
const f = filter();
|
// caller observes the defaults: "all" visibility and the stable
|
||||||
|
// "updated desc" sort with pinned feeds first.
|
||||||
if (f.visibility && f.visibility !== "all") {
|
const result = [...feeds()];
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result.sort(
|
||||||
}
|
(a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(),
|
||||||
|
);
|
||||||
if (f.sourceId) {
|
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.pinnedOnly) {
|
|
||||||
result = result.filter((feed) => feed.isPinned);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.searchQuery) {
|
|
||||||
const query = f.searchQuery.toLowerCase();
|
|
||||||
result = result.filter(
|
|
||||||
(feed) =>
|
|
||||||
feed.podcast.title.toLowerCase().includes(query) ||
|
|
||||||
feed.customName?.toLowerCase().includes(query) ||
|
|
||||||
feed.podcast.description?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
|
||||||
result.sort((a, b) => {
|
|
||||||
switch (f.sortBy) {
|
|
||||||
case "title":
|
|
||||||
return (
|
|
||||||
sortDir *
|
|
||||||
(a.customName || a.podcast.title).localeCompare(
|
|
||||||
b.customName || b.podcast.title,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case "episodeCount":
|
|
||||||
return sortDir * (a.episodes.length - b.episodes.length);
|
|
||||||
case "latestEpisode":
|
|
||||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
|
||||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
|
||||||
return sortDir * (aLatest - bLatest);
|
|
||||||
case "updated":
|
|
||||||
default:
|
|
||||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -425,17 +270,8 @@ function createFeedStore() {
|
|||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const xml = await fetchFeedXml(feedUrl);
|
||||||
headers: {
|
if (xml === null) return { episodes: null, coverUrl: undefined };
|
||||||
"Accept-Encoding": "identity",
|
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
||||||
},
|
|
||||||
// Hung feeds must not stall a refresh batch (or the
|
|
||||||
// background refresh loop) indefinitely.
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) return { episodes: null, coverUrl: undefined };
|
|
||||||
const xml = await response.text();
|
|
||||||
// Yield after the network read so the renderer gets a turn
|
// Yield after the network read so the renderer gets a turn
|
||||||
// before the sync regex + parse work begins.
|
// before the sync regex + parse work begins.
|
||||||
await yieldToUI();
|
await yieldToUI();
|
||||||
@@ -712,8 +548,8 @@ function createFeedStore() {
|
|||||||
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
||||||
// keychain and are stripped from config.json.
|
// keychain and are stripped from config.json.
|
||||||
const secured = await migratePlaintextCredentials(mergedSources);
|
const secured = await migratePlaintextCredentials(mergedSources);
|
||||||
setSources(secured);
|
registry.replaceAll(secured);
|
||||||
if (secured !== mergedSources) saveSources(secured);
|
if (secured !== mergedSources) saveSourcesToFile(secured);
|
||||||
}
|
}
|
||||||
await refreshAllFeeds();
|
await refreshAllFeeds();
|
||||||
})();
|
})();
|
||||||
@@ -772,71 +608,6 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
|
||||||
setFeeds((prev) => {
|
|
||||||
const updated = prev.map((f) =>
|
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
|
||||||
);
|
|
||||||
scheduleSaveFeeds();
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePinned = (feedId: string) => {
|
|
||||||
setFeeds((prev) => {
|
|
||||||
const updated = prev.map((f) =>
|
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
|
||||||
);
|
|
||||||
scheduleSaveFeeds();
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
|
||||||
const newSource: PodcastSource = {
|
|
||||||
...source,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
};
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = [...prev, newSource];
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return newSource;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.map((source) =>
|
|
||||||
source.id === sourceId ? { ...source, ...updates } : source,
|
|
||||||
);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSource = (sourceId: string) => {
|
|
||||||
// Don't remove default sources
|
|
||||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
|
||||||
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.filter((s) => s.id !== sourceId);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSource = (sourceId: string) => {
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.map((s) =>
|
|
||||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
|
||||||
);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
return feeds().find((f) => f.id === feedId);
|
return feeds().find((f) => f.id === feedId);
|
||||||
};
|
};
|
||||||
@@ -851,11 +622,6 @@ function createFeedStore() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
|
||||||
const id = selectedFeedId();
|
|
||||||
return id ? getFeed(id) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently
|
/** Check if a feed has more episodes available beyond what's currently
|
||||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||||
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||||
@@ -876,7 +642,7 @@ function createFeedStore() {
|
|||||||
loaded,
|
loaded,
|
||||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
);
|
);
|
||||||
return epTs(cached[loaded]) >= cutoff;
|
return episodeTs(cached[loaded]) >= cutoff;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for one feed from the full parse
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
@@ -898,17 +664,8 @@ function createFeedStore() {
|
|||||||
// restart). The cache holds the FULL parse — no bound applied here.
|
// restart). The cache holds the FULL parse — no bound applied here.
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
const xml = await fetchFeedXml(feed.podcast.feedUrl);
|
||||||
headers: {
|
if (xml === null) return;
|
||||||
"Accept-Encoding": "identity",
|
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
||||||
},
|
|
||||||
// A hung feed must not stall the load-more path forever —
|
|
||||||
// mirror fetchEpisodes' per-feed timeout.
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) return;
|
|
||||||
const xml = await response.text();
|
|
||||||
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Failed/hung refetch: leave the feed's loaded episodes
|
// Failed/hung refetch: leave the feed's loaded episodes
|
||||||
@@ -946,13 +703,7 @@ function createFeedStore() {
|
|||||||
currentCount,
|
currentCount,
|
||||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
);
|
);
|
||||||
newCount = currentCount;
|
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||||
while (
|
|
||||||
newCount < cached.length &&
|
|
||||||
epTs(cached[newCount]) >= cutoff
|
|
||||||
) {
|
|
||||||
newCount++;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||||
}
|
}
|
||||||
@@ -1039,14 +790,7 @@ function createFeedStore() {
|
|||||||
currentCount,
|
currentCount,
|
||||||
windowDays,
|
windowDays,
|
||||||
);
|
);
|
||||||
if (epTs(cached[currentCount]) < cutoff) continue;
|
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||||
newCount = currentCount;
|
|
||||||
while (
|
|
||||||
newCount < cached.length &&
|
|
||||||
epTs(cached[newCount]) >= cutoff
|
|
||||||
) {
|
|
||||||
newCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (newCount <= currentCount) continue;
|
if (newCount <= currentCount) continue;
|
||||||
episodeLoadCount.set(feed.id, newCount);
|
episodeLoadCount.set(feed.id, newCount);
|
||||||
@@ -1083,9 +827,7 @@ function createFeedStore() {
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
feeds,
|
feeds,
|
||||||
sources,
|
sources: registry.sources,
|
||||||
filter,
|
|
||||||
selectedFeedId,
|
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
|
||||||
/** Resolves once persisted feeds are loaded from disk (before the
|
/** Resolves once persisted feeds are loaded from disk (before the
|
||||||
@@ -1097,40 +839,36 @@ function createFeedStore() {
|
|||||||
getAllEpisodesChronological,
|
getAllEpisodesChronological,
|
||||||
getFeed,
|
getFeed,
|
||||||
findEpisode,
|
findEpisode,
|
||||||
getSelectedFeed,
|
|
||||||
hasMoreEpisodes,
|
hasMoreEpisodes,
|
||||||
isLoadingFeeds,
|
isLoadingFeeds,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
|
||||||
setSelectedFeedId,
|
|
||||||
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
||||||
* record (Discover's episode preview). Pass no feedId to skip the
|
* record (Discover's episode preview). Pass no feedId to skip the
|
||||||
* full-parse cache; the visible window is bounded by the user's
|
* full-parse cache; the visible window is bounded by the user's
|
||||||
* cache preference and `limit`. */
|
* cache preference and `limit`. */
|
||||||
fetchEpisodes,
|
fetchEpisodes,
|
||||||
addFeed,
|
addFeed,
|
||||||
hasFeedByUrl,
|
|
||||||
removeFeed,
|
removeFeed,
|
||||||
removeFeedByUrl,
|
removeFeedByUrl,
|
||||||
updateFeed,
|
|
||||||
togglePinned,
|
|
||||||
refreshFeed,
|
refreshFeed,
|
||||||
refreshAllFeeds,
|
refreshAllFeeds,
|
||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
loadMoreAllFeeds,
|
loadMoreAllFeeds,
|
||||||
hasMoreAcrossAll,
|
hasMoreAcrossAll,
|
||||||
flushPendingSave,
|
flushPendingSave,
|
||||||
addSource,
|
addSource: registry.addSource,
|
||||||
removeSource,
|
toggleSource: registry.toggleSource,
|
||||||
toggleSource,
|
updateSource: registry.updateSource,
|
||||||
updateSource,
|
|
||||||
runAutoDownload: runAutoDownloadNow,
|
runAutoDownload: runAutoDownloadNow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
|
/** Re-exported: refresh-merge tests import it from the store module. */
|
||||||
|
export { sameRefreshWindow } from "../utils/episode-windows";
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
if (!feedStoreInstance) {
|
if (!feedStoreInstance) {
|
||||||
feedStoreInstance = createFeedStore();
|
feedStoreInstance = createFeedStore();
|
||||||
|
|||||||
31
src/utils/rss-client.ts
Normal file
31
src/utils/rss-client.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* RSS feed client — single owner of feed XML fetches: headers, timeout,
|
||||||
|
* and failure folding to null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default per-feed fetch timeout (ms). */
|
||||||
|
export const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a feed's raw XML. Identity encoding keeps the response raw; the
|
||||||
|
* Accept list matches what podcast servers send. Any failure (network,
|
||||||
|
* non-ok, timeout) resolves to null — callers must leave data untouched.
|
||||||
|
*/
|
||||||
|
export const fetchFeedXml = async (
|
||||||
|
url: string,
|
||||||
|
opts?: { timeoutMs?: number },
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(opts?.timeoutMs ?? FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
|
import { fetchFeedXml } from "./rss-client";
|
||||||
import { SourceType } from "../types/source";
|
import { SourceType } from "../types/source";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source";
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
|
|
||||||
@@ -81,15 +82,8 @@ export const searchByFeedUrl = async (
|
|||||||
if (!FEED_URL_RE.test(trimmed)) return [];
|
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(trimmed, {
|
const xml = await fetchFeedXml(trimmed);
|
||||||
headers: {
|
if (xml === null) return [];
|
||||||
"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);
|
const podcast = parseRSSFeed(xml, trimmed);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user