feat(feed): signature-aware volatile merge with date-banded fetch-more
This commit is contained in:
@@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
kind: "select",
|
||||
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||
help: () =>
|
||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: keep the N most recent episodes (see Cache Count below); Fetch More pages in 50-episode chunks.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const idx = CACHE_MODE_LABELS.findIndex(
|
||||
(s) => s.value === prefs().episodeCacheMode,
|
||||
|
||||
@@ -13,8 +13,12 @@ import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||
import {
|
||||
episodeSignature,
|
||||
mergeEpisodesBounded,
|
||||
} from "../utils/episode-merge";
|
||||
import {
|
||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
episodeInWindow,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
@@ -139,6 +143,31 @@ const epTs = (ep: Episode): number => {
|
||||
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;
|
||||
@@ -203,12 +232,21 @@ async function migratePlaintextCredentials(
|
||||
* 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. */
|
||||
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
||||
* `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));
|
||||
return fetched.every((e) => ids.has(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)));
|
||||
}
|
||||
|
||||
/** Run `fn` over every item with at most `limit` executions in flight — a
|
||||
@@ -249,6 +287,11 @@ function createFeedStore() {
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
||||
* at episodeCacheCount × (presses + 1) episodes, so one press reveals
|
||||
* exactly N more of the NEWEST episodes across all shows — it can never
|
||||
* dump deep history (see getAllEpisodesChronological). */
|
||||
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
||||
|
||||
// ── Debounced persistence ───────────────────────────────────────────────
|
||||
/** Trailing-edge debounce window for config.json writes. */
|
||||
@@ -357,6 +400,20 @@ function createFeedStore() {
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
|
||||
// COUNT mode: the Feed page is a GLOBAL top-K list — the newest
|
||||
// `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL
|
||||
// shows, not N per show. A press reveals exactly N more recent
|
||||
// episodes; deep history never surfaces in one jump. The cap stays
|
||||
// even once every cache is exhausted (the button hides) — lifting it
|
||||
// rendered the full deep union and froze the UI.
|
||||
const prefs = useAppStore().state().preferences;
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const limit =
|
||||
Math.max(1, prefs.episodeCacheCount ?? 25) *
|
||||
(countFetchMorePresses() + 1);
|
||||
return allEpisodes.slice(0, limit);
|
||||
}
|
||||
|
||||
return allEpisodes;
|
||||
};
|
||||
|
||||
@@ -814,15 +871,25 @@ function createFeedStore() {
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently
|
||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||
* cache bound), so fetch-more can always page deeper — the bound limits
|
||||
* what the Feed/My Shows list shows initially, not what fetch-more can
|
||||
* reach. When the loaded window reaches the cache length, this flips
|
||||
* false. */
|
||||
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||
* when the next unloaded episode falls inside the next 2-week band: a
|
||||
* sparse/dormant show whose band is empty reports false, so fetch-more
|
||||
* never drags in arbitrarily old episodes just because the parse cache
|
||||
* holds them. When the loaded window reaches the cache length (or the
|
||||
* band is empty), this flips false. */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
if (loaded >= cached.length) return false;
|
||||
const prefs = useAppStore().state().preferences;
|
||||
if (prefs.episodeCacheMode === "count") return true;
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
loaded,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
return epTs(cached[loaded]) >= cutoff;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the full parse
|
||||
@@ -879,29 +946,26 @@ function createFeedStore() {
|
||||
const prefs = useAppStore().state().preferences;
|
||||
|
||||
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
|
||||
// past the oldest loaded episode — a daily show gains ~2 weeks of
|
||||
// episodes, a weekly show gains its next 2, never a fixed count.
|
||||
// past the oldest loaded episode (or the cache-window edge when
|
||||
// nothing is loaded) — a daily show gains ~2 weeks of episodes, a
|
||||
// weekly show gains its next 2, never a fixed count. An empty band
|
||||
// is a genuine stop (hasMoreEpisodes hides the button) — no minimum,
|
||||
// so a sparse/dormant show can't grab arbitrarily old episodes.
|
||||
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
|
||||
let newCount: number;
|
||||
if (prefs.episodeCacheMode === "date") {
|
||||
const ref = cached[Math.max(0, currentCount - 1)];
|
||||
const refTs = ref ? epTs(ref) : Infinity;
|
||||
if (Number.isFinite(refTs)) {
|
||||
const cutoff = refTs - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
} else {
|
||||
newCount = currentCount;
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
currentCount,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
// Date mode always advances at least one episode: a sparse band
|
||||
// (a show that went quiet) must not wedge the button into a
|
||||
// no-op while hasMoreEpisodes still reports true.
|
||||
newCount = Math.max(newCount, currentCount + 1);
|
||||
} else {
|
||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||
}
|
||||
@@ -947,15 +1011,78 @@ function createFeedStore() {
|
||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||
};
|
||||
|
||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||
/** Power the Feed page's "[Fetch More]".
|
||||
* Date mode: advance each feed's window by its 2-week band (empty bands
|
||||
* — sparse/dormant shows — are skipped).
|
||||
* Count mode: the global list cap grows by one count (see
|
||||
* getAllEpisodesChronological) and every feed's window deepens by one
|
||||
* count so the growing cap has material; one press reveals exactly N
|
||||
* more RECENT episodes, never a far-back dump.
|
||||
* Both modes compute every feed's new window FIRST (yielding between
|
||||
* feeds so the renderer keeps painting) and apply ONE setFeeds — the
|
||||
* Feed list rebuilds once per press instead of once per feed (the
|
||||
* per-feed storms froze the UI). */
|
||||
const loadMoreAllFeeds = async () => {
|
||||
if (isLoadingMore()) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||
for (const feed of pending) {
|
||||
await loadMoreEpisodesForFeed(feed.id);
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
setCountFetchMorePresses((p) => p + 1);
|
||||
}
|
||||
const windowDays =
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS;
|
||||
|
||||
const updates: Array<{ feedId: string; episodes: Episode[] }> = [];
|
||||
for (const feed of feeds()) {
|
||||
const cached = fullEpisodeCache.get(feed.id);
|
||||
if (!cached) continue;
|
||||
const currentCount =
|
||||
episodeLoadCount.get(feed.id) ?? feed.episodes.length;
|
||||
if (currentCount >= cached.length) continue;
|
||||
let newCount: number;
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
newCount = Math.min(currentCount + count, cached.length);
|
||||
} else {
|
||||
// Date mode: skip feeds whose next band is empty — the
|
||||
// button must not surface arbitrarily old episodes.
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
currentCount,
|
||||
windowDays,
|
||||
);
|
||||
if (epTs(cached[currentCount]) < cutoff) continue;
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
if (newCount <= currentCount) continue;
|
||||
episodeLoadCount.set(feed.id, newCount);
|
||||
updates.push({
|
||||
feedId: feed.id,
|
||||
episodes: cached.slice(0, newCount),
|
||||
});
|
||||
// Yield so the renderer paints between feed computations.
|
||||
await yieldToUI();
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
const byId = new Map(
|
||||
updates.map((u) => [u.feedId, u.episodes]),
|
||||
);
|
||||
setFeeds((prev) =>
|
||||
prev.map((f) =>
|
||||
byId.has(f.id)
|
||||
? { ...f, episodes: byId.get(f.id)! }
|
||||
: f,
|
||||
),
|
||||
);
|
||||
scheduleSaveFeeds();
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@@ -990,6 +1117,11 @@ function createFeedStore() {
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
||||
* record (Discover's episode preview). Pass no feedId to skip the
|
||||
* full-parse cache; the visible window is bounded by the user's
|
||||
* cache preference and `limit`. */
|
||||
fetchEpisodes,
|
||||
addFeed,
|
||||
hasFeedByUrl,
|
||||
removeFeed,
|
||||
|
||||
@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t
|
||||
}
|
||||
|
||||
/** PubDate stamp for identity matching — undated episodes collapse to a
|
||||
* single token so their twins match by title alone. */
|
||||
const stamp = (ep: Episode): string => {
|
||||
const t = ep.pubDate?.getTime()
|
||||
return t === undefined || Number.isNaN(t) ? "undated" : String(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content signature identifying the SAME episode across id changes. Episode
|
||||
* ids are stable (guid / enclosure-URL derived), but a feed can still change
|
||||
* an episode's id between refreshes: the one-time migration from the old
|
||||
* positional-id scheme, or a host that rotates signed enclosure URLs. title +
|
||||
* pubDate is the most stable combination that survives both — a feed
|
||||
* re-issuing an episode with the same title and date IS that episode.
|
||||
*/
|
||||
export const episodeSignature = (ep: Episode): string =>
|
||||
`${ep.title}\u0000${stamp(ep)}`
|
||||
|
||||
/**
|
||||
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
|
||||
* by the supplied `keep` predicate: episodes outside the configured cache
|
||||
* bound (date window or count) are dropped. Never mutates either input.
|
||||
* wins (fresh metadata). An existing episode whose id differs from every
|
||||
* fetched id but whose content signature matches a fetched episode is a
|
||||
* stale-id twin (id migration / rotating enclosure URLs) and is dropped,
|
||||
* otherwise the union would double every episode on the first refresh after
|
||||
* the id scheme changed. Existing episodes with NO fetched twin survive
|
||||
* (volatile in-memory window). Result is sorted newest-first by pubDate and
|
||||
* pruned by the supplied `keep` predicate: episodes outside the configured
|
||||
* cache bound (date window or count) are dropped. Never mutates either
|
||||
* input.
|
||||
*
|
||||
* The caller supplies `keep` so this module stays free of the preference
|
||||
* types — the feed store passes a closure bound to the user's mode/count/days.
|
||||
@@ -23,8 +47,17 @@ export function mergeEpisodesBounded(
|
||||
keep: (ep: Episode, index: number) => boolean,
|
||||
): Episode[] {
|
||||
const byId = new Map<string, Episode>()
|
||||
for (const ep of existing) byId.set(ep.id, ep)
|
||||
for (const ep of fetched) byId.set(ep.id, ep)
|
||||
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
||||
const bySignature = new Map<string, Episode>()
|
||||
for (const ep of fetched) {
|
||||
byId.set(ep.id, ep)
|
||||
bySignature.set(episodeSignature(ep), ep)
|
||||
}
|
||||
const merged = [...byId.values()]
|
||||
for (const ep of existing) {
|
||||
if (byId.has(ep.id)) continue
|
||||
if (bySignature.has(episodeSignature(ep))) continue
|
||||
merged.push(ep)
|
||||
}
|
||||
const sorted = merged.sort((a, b) => ts(b) - ts(a))
|
||||
return sorted.filter((ep, i) => keep(ep, i))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user