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",
|
kind: "select",
|
||||||
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
help: () =>
|
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) => {
|
cycle: (dir) => {
|
||||||
const idx = CACHE_MODE_LABELS.findIndex(
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
(s) => s.value === prefs().episodeCacheMode,
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import { DEFAULT_SOURCES } from "../types/source";
|
|||||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
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,
|
||||||
|
mergeEpisodesBounded,
|
||||||
|
} from "../utils/episode-merge";
|
||||||
|
import {
|
||||||
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
episodeInWindow,
|
episodeInWindow,
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
@@ -139,6 +143,31 @@ const epTs = (ep: Episode): number => {
|
|||||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
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). */
|
/** Save feeds to file (async, fire-and-forget). */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
const prefs = useAppStore().state().preferences;
|
const prefs = useAppStore().state().preferences;
|
||||||
@@ -203,12 +232,21 @@ async function migratePlaintextCredentials(
|
|||||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||||
* fetched window, so unchanged-detection must compare the fetched window
|
* fetched window, so unchanged-detection must compare the fetched window
|
||||||
* against the existing list's prefix — comparing full lists would bump
|
* against the existing list's prefix — comparing full lists would bump
|
||||||
* `lastUpdated` on every refresh. */
|
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||||
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
* 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;
|
if (fetched.length === 0) return true;
|
||||||
const prefix = existing.slice(0, fetched.length);
|
const prefix = existing.slice(0, fetched.length);
|
||||||
const ids = new Set(prefix.map((e) => e.id));
|
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
|
/** 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 [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
|
||||||
|
* 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 ───────────────────────────────────────────────
|
// ── Debounced persistence ───────────────────────────────────────────────
|
||||||
/** Trailing-edge debounce window for config.json writes. */
|
/** Trailing-edge debounce window for config.json writes. */
|
||||||
@@ -357,6 +400,20 @@ function createFeedStore() {
|
|||||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
(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;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -814,15 +871,25 @@ function createFeedStore() {
|
|||||||
|
|
||||||
/** 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 always page deeper — the bound limits
|
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||||
* what the Feed/My Shows list shows initially, not what fetch-more can
|
* when the next unloaded episode falls inside the next 2-week band: a
|
||||||
* reach. When the loaded window reaches the cache length, this flips
|
* sparse/dormant show whose band is empty reports false, so fetch-more
|
||||||
* false. */
|
* 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 hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const cached = fullEpisodeCache.get(feedId);
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
if (!cached) return false;
|
if (!cached) return false;
|
||||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
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
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
@@ -879,29 +946,26 @@ function createFeedStore() {
|
|||||||
const prefs = useAppStore().state().preferences;
|
const prefs = useAppStore().state().preferences;
|
||||||
|
|
||||||
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
|
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
|
||||||
// past the oldest loaded episode — a daily show gains ~2 weeks of
|
// past the oldest loaded episode (or the cache-window edge when
|
||||||
// episodes, a weekly show gains its next 2, never a fixed count.
|
// 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.
|
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
|
||||||
let newCount: number;
|
let newCount: number;
|
||||||
if (prefs.episodeCacheMode === "date") {
|
if (prefs.episodeCacheMode === "date") {
|
||||||
const ref = cached[Math.max(0, currentCount - 1)];
|
const cutoff = dateFetchMoreCutoff(
|
||||||
const refTs = ref ? epTs(ref) : Infinity;
|
cached,
|
||||||
if (Number.isFinite(refTs)) {
|
currentCount,
|
||||||
const cutoff = refTs - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
newCount = currentCount;
|
);
|
||||||
while (
|
newCount = currentCount;
|
||||||
newCount < cached.length &&
|
while (
|
||||||
epTs(cached[newCount]) >= cutoff
|
newCount < cached.length &&
|
||||||
) {
|
epTs(cached[newCount]) >= cutoff
|
||||||
newCount++;
|
) {
|
||||||
}
|
newCount++;
|
||||||
} else {
|
|
||||||
newCount = currentCount;
|
|
||||||
}
|
}
|
||||||
// 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 {
|
} else {
|
||||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||||
}
|
}
|
||||||
@@ -947,15 +1011,78 @@ function createFeedStore() {
|
|||||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
/** Power the Feed page's "[Fetch More]".
|
||||||
* still has cached episodes — powers 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 () => {
|
const loadMoreAllFeeds = async () => {
|
||||||
if (isLoadingMore()) return;
|
if (isLoadingMore()) return;
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
const prefs = useAppStore().state().preferences;
|
||||||
for (const feed of pending) {
|
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
|
||||||
await loadMoreEpisodesForFeed(feed.id);
|
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 {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
@@ -990,6 +1117,11 @@ function createFeedStore() {
|
|||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
setFilter,
|
||||||
setSelectedFeedId,
|
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,
|
addFeed,
|
||||||
hasFeedByUrl,
|
hasFeedByUrl,
|
||||||
removeFeed,
|
removeFeed,
|
||||||
|
|||||||
@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
|
|||||||
return t === undefined || Number.isNaN(t) ? Infinity : t
|
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
|
* 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
|
* wins (fresh metadata). An existing episode whose id differs from every
|
||||||
* by the supplied `keep` predicate: episodes outside the configured cache
|
* fetched id but whose content signature matches a fetched episode is a
|
||||||
* bound (date window or count) are dropped. Never mutates either input.
|
* 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
|
* 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.
|
* 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,
|
keep: (ep: Episode, index: number) => boolean,
|
||||||
): Episode[] {
|
): Episode[] {
|
||||||
const byId = new Map<string, Episode>()
|
const byId = new Map<string, Episode>()
|
||||||
for (const ep of existing) byId.set(ep.id, ep)
|
const bySignature = new Map<string, Episode>()
|
||||||
for (const ep of fetched) byId.set(ep.id, ep)
|
for (const ep of fetched) {
|
||||||
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
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))
|
return sorted.filter((ep, i) => keep(ep, i))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ tests:
|
|||||||
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
||||||
- Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
|
- Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
|
||||||
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
|
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
|
||||||
|
- Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in `loadMoreAllFeedsByCount`.
|
||||||
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
|
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
|
||||||
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
|
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
|
||||||
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
|
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const addedFeedIds: string[] = [];
|
|||||||
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||||
let debounceFeedId = "";
|
let debounceFeedId = "";
|
||||||
|
|
||||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
|
|||||||
// store (execution order between files is not guaranteed).
|
// store (execution order between files is not guaranteed).
|
||||||
const addedFeedIds: string[] = [];
|
const addedFeedIds: string[] = [];
|
||||||
|
|
||||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
@@ -71,7 +71,10 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
|||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
|
// The app store loads its persisted prefs asynchronously at import; wait
|
||||||
|
// for that so our count-mode override isn't clobbered by the load.
|
||||||
|
await useAppStore().whenReady();
|
||||||
// Chunk-based stepping is count-mode behavior (see header comment).
|
// Chunk-based stepping is count-mode behavior (see header comment).
|
||||||
useAppStore().updatePreferences({
|
useAppStore().updatePreferences({
|
||||||
episodeCacheMode: "count",
|
episodeCacheMode: "count",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ let feedAId = "";
|
|||||||
/** When set, the server 503s this path — simulates a feed going down. */
|
/** When set, the server 503s this path — simulates a feed going down. */
|
||||||
let failPath: string | null = null;
|
let failPath: string | null = null;
|
||||||
|
|
||||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
const items = episodes
|
const items = episodes
|
||||||
.map(
|
.map(
|
||||||
@@ -79,6 +79,18 @@ beforeAll(() => {
|
|||||||
if (failPath && url.pathname === failPath) {
|
if (failPath && url.pathname === failPath) {
|
||||||
return new Response("feed unavailable", { status: 503 });
|
return new Response("feed unavailable", { status: 503 });
|
||||||
}
|
}
|
||||||
|
// A dedicated single-episode feed for the failed-refresh test:
|
||||||
|
// it must not depend on (or shrink) the shared servedEpisodes
|
||||||
|
// list, which other tests' feeds read on refreshAllFeeds.
|
||||||
|
if (url.pathname === "/flaky.xml") {
|
||||||
|
return new Response(
|
||||||
|
feedXml(
|
||||||
|
[{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }],
|
||||||
|
url.origin,
|
||||||
|
),
|
||||||
|
{ headers: { "Content-Type": "application/rss+xml" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
if (url.pathname.endsWith(".xml")) {
|
if (url.pathname.endsWith(".xml")) {
|
||||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
headers: { "Content-Type": "application/rss+xml" },
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
@@ -139,8 +151,9 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
|||||||
|
|
||||||
test("a failed refresh does not wipe the feed's episodes", async () => {
|
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
const savedEpisodes = servedEpisodes;
|
// /flaky.xml serves its own fixed single-episode feed (see server route)
|
||||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
// so the shared servedEpisodes list stays untouched for feedA, which
|
||||||
|
// refreshAllFeeds below also refreshes.
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
expect(feed).not.toBeNull();
|
expect(feed).not.toBeNull();
|
||||||
@@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
|||||||
|
|
||||||
failPath = null;
|
failPath = null;
|
||||||
store.removeFeed(feedId);
|
store.removeFeed(feedId);
|
||||||
// Restore the shared served content: with union merge semantics (volatile
|
|
||||||
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
|
||||||
// so later tests must serve the same episodes they added — a shrink here
|
|
||||||
// would make the next test's "unchanged" refresh genuinely different.
|
|
||||||
servedEpisodes = savedEpisodes;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { join } from "path";
|
|||||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||||
process.env.XDG_CONFIG_HOME = configHome;
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
import { useFeedStore } from "../src/stores/feed";
|
import { sameRefreshWindow, useFeedStore } from "../src/stores/feed";
|
||||||
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
||||||
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||||
import { useAppStore } from "../src/stores/app";
|
import { useAppStore } from "../src/stores/app";
|
||||||
@@ -132,6 +132,67 @@ test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy",
|
|||||||
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded drops stale-id twins (id migration / rotating enclosure URLs)", () => {
|
||||||
|
// The same two episodes with different ids on both sides — exactly what a
|
||||||
|
// refresh sees after the positional-id → stable-id migration (or a host
|
||||||
|
// that rotates signed audio URLs). Without content matching the union
|
||||||
|
// would double every episode.
|
||||||
|
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||||
|
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("feed#0", "Ep 1", d1),
|
||||||
|
makeEpisode("feed#1", "Ep 2", d2),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("feed#guid:g1", "Ep 1", d1),
|
||||||
|
makeEpisode("feed#guid:g2", "Ep 2", d2),
|
||||||
|
];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["feed#guid:g2", "feed#guid:g1"]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded keeps an existing episode with no fetched twin (volatile window)", () => {
|
||||||
|
// Fetched covers Ep 1 only (by content twin). Ep 2 exists only in memory
|
||||||
|
// — the volatile window — and must survive the refresh.
|
||||||
|
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||||
|
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("feed#0", "Ep 1", d1),
|
||||||
|
makeEpisode("feed#1", "Ep 2", d2),
|
||||||
|
];
|
||||||
|
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d1)];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.map((e) => e.title).sort()).toEqual(["Ep 1", "Ep 2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sameRefreshWindow treats id drift with identical content as unchanged", () => {
|
||||||
|
// Same episode, id changed between refreshes (migration / URL rotation):
|
||||||
|
// the refresh must NOT bump lastUpdated or re-render.
|
||||||
|
const d = new Date("2026-08-01T00:00:00Z");
|
||||||
|
const existing = [makeEpisode("feed#0", "Ep 1", d)];
|
||||||
|
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d)];
|
||||||
|
expect(sameRefreshWindow(existing, fetched)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sameRefreshWindow flags a genuinely new episode even when ids drift", () => {
|
||||||
|
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||||
|
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||||
|
const existing = [makeEpisode("feed#0", "Ep 1", d1)];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("feed#guid:g2", "Ep 2", d2),
|
||||||
|
makeEpisode("feed#guid:g1", "Ep 1", d1),
|
||||||
|
];
|
||||||
|
expect(sameRefreshWindow(existing, fetched)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||||
const existing = [
|
const existing = [
|
||||||
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||||
@@ -284,13 +345,57 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
|
|||||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
"In Window",
|
"In Window",
|
||||||
]);
|
]);
|
||||||
// The full cache holds both, but the visible list only shows the in-window
|
// The 70d episode is ~45 days past the 2-week band beyond the oldest
|
||||||
// one — fetch-more surfaces the out-of-window one (volatile).
|
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
|
||||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
await store.loadMoreEpisodes(id);
|
await store.loadMoreEpisodes(id);
|
||||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
"In Window",
|
"In Window",
|
||||||
"Out Window",
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode: a dormant show (nothing in the window or next band) never fetch-mores", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
// Newest episode 100 days old, next 200 days old — both far outside the
|
||||||
|
// 60-day cache window and the 14-day band past its edge.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Old A", date: new Date(now - 100 * DAY).toISOString() },
|
||||||
|
{ title: "Old B", date: new Date(now - 200 * DAY).toISOString() },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/dormant.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
// Both episodes are outside the 60-day window (61d / 65d) but inside the
|
||||||
|
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() },
|
||||||
|
{ title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/just-out.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
|
"Just Out A",
|
||||||
|
"Just Out B",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,6 +439,9 @@ test("date mode: fetch-more steps by a two-week window, not a count", async () =
|
|||||||
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
// The app store loads persisted prefs asynchronously at import — wait so
|
||||||
|
// the override below isn't clobbered by the load.
|
||||||
|
await app.whenReady();
|
||||||
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -364,3 +472,67 @@ test("count mode: only N most-recent episodes are visible, but fetch-more goes b
|
|||||||
// Reset to date mode for subsequent tests.
|
// Reset to date mode for subsequent tests.
|
||||||
app.updatePreferences({ episodeCacheMode: "date" });
|
app.updatePreferences({ episodeCacheMode: "date" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("count mode: Feed list is a GLOBAL top-N that grows N per press, never a far-back dump", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const app = useAppStore();
|
||||||
|
// Wait out the async pref load (see the single-show count test).
|
||||||
|
await app.whenReady();
|
||||||
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// Feed A: 200 episodes at 1-day spacing (ages 0–199d). Feed B: 200 at
|
||||||
|
// 1-day spacing shifted 200 days older (ages 200–399d) — every A episode
|
||||||
|
// is newer than every B episode, so the global top-K is deterministic.
|
||||||
|
const serve = (prefix: string, shiftDays: number) =>
|
||||||
|
Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
title: `${prefix} Ep ${200 - i}`,
|
||||||
|
date: new Date(now - (shiftDays + i) * DAY).toISOString(),
|
||||||
|
}));
|
||||||
|
servedEpisodes = serve("A", 0);
|
||||||
|
const aUrl = `http://127.0.0.1:${server!.port}/global-a.xml`;
|
||||||
|
const a = await store.addFeed(makePodcast(aUrl), "test-source");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
const aId = a!.id;
|
||||||
|
addedFeedIds.push(aId);
|
||||||
|
servedEpisodes = serve("B", 200);
|
||||||
|
const bUrl = `http://127.0.0.1:${server!.port}/global-b.xml`;
|
||||||
|
const b = await store.addFeed(makePodcast(bUrl), "test-source");
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
const bId = b!.id;
|
||||||
|
addedFeedIds.push(bId);
|
||||||
|
|
||||||
|
// The Feed page's global list is capped at the configured count (25),
|
||||||
|
// NOT 20 per show (the union would be 40).
|
||||||
|
expect(store.getAllEpisodesChronological().length).toBe(25);
|
||||||
|
|
||||||
|
// Press 1: cap grows to 50 AND every feed's window deepens by 25 — the
|
||||||
|
// list reveals exactly the next 25 most-recent episodes (A's 25 more),
|
||||||
|
// not 25 from every show.
|
||||||
|
await store.loadMoreAllFeeds();
|
||||||
|
expect(store.getAllEpisodesChronological().length).toBe(50);
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(45);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(45);
|
||||||
|
expect(store.hasMoreAcrossAll()).toBe(true);
|
||||||
|
|
||||||
|
// Press 2: cap grows to 75.
|
||||||
|
await store.loadMoreAllFeeds();
|
||||||
|
expect(store.getAllEpisodesChronological().length).toBe(75);
|
||||||
|
|
||||||
|
// Keep pressing until every cache is exhausted. The global cap stays
|
||||||
|
// (never lifts — rendering the full deep union froze the UI), so the
|
||||||
|
// Feed list stays at count×(presses+1) = 25×9 = 225 while the per-show
|
||||||
|
// windows hold everything.
|
||||||
|
let guard = 0;
|
||||||
|
while (store.hasMoreAcrossAll() && guard++ < 30) {
|
||||||
|
await store.loadMoreAllFeeds();
|
||||||
|
}
|
||||||
|
expect(guard).toBeLessThan(30);
|
||||||
|
expect(store.hasMoreAcrossAll()).toBe(false);
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(200);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(200);
|
||||||
|
expect(store.getAllEpisodesChronological().length).toBe(225);
|
||||||
|
|
||||||
|
// Reset to date mode for subsequent tests.
|
||||||
|
app.updatePreferences({ episodeCacheMode: "date" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
|
|||||||
process.env.XDG_DATA_HOME = DATA;
|
process.env.XDG_DATA_HOME = DATA;
|
||||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
// ── Local RSS feed server (episode ids derive from enclosure URLs) ─────────
|
||||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
function feedXml(origin: string): string {
|
function feedXml(origin: string): string {
|
||||||
const items = ["Episode One", "Episode Two"]
|
const items = ["Episode One", "Episode Two"]
|
||||||
|
|||||||
Reference in New Issue
Block a user