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,15 +946,19 @@ 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;
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
currentCount,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
@@ -895,13 +966,6 @@ function createFeedStore() {
|
||||
) {
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -62,7 +62,7 @@ const addedFeedIds: string[] = [];
|
||||
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||
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 {
|
||||
const items = episodes
|
||||
.map(
|
||||
|
||||
@@ -42,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
|
||||
// store (execution order between files is not guaranteed).
|
||||
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 {
|
||||
const items = episodes
|
||||
.map(
|
||||
@@ -71,7 +71,10 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
||||
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).
|
||||
useAppStore().updatePreferences({
|
||||
episodeCacheMode: "count",
|
||||
|
||||
@@ -42,7 +42,7 @@ let feedAId = "";
|
||||
/** When set, the server 503s this path — simulates a feed going down. */
|
||||
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 {
|
||||
const items = episodes
|
||||
.map(
|
||||
@@ -79,6 +79,18 @@ beforeAll(() => {
|
||||
if (failPath && url.pathname === failPath) {
|
||||
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")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
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 () => {
|
||||
const store = useFeedStore();
|
||||
const savedEpisodes = servedEpisodes;
|
||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||
// /flaky.xml serves its own fixed single-episode feed (see server route)
|
||||
// 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 feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
@@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
|
||||
failPath = null;
|
||||
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 () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { join } from "path";
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||
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 { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const existing = [
|
||||
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([
|
||||
"In Window",
|
||||
]);
|
||||
// The full cache holds both, but the visible list only shows the in-window
|
||||
// one — fetch-more surfaces the out-of-window one (volatile).
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
// The 70d episode is ~45 days past the 2-week band beyond the oldest
|
||||
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||
"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 () => {
|
||||
const store = useFeedStore();
|
||||
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 });
|
||||
|
||||
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.
|
||||
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.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;
|
||||
function feedXml(origin: string): string {
|
||||
const items = ["Episode One", "Episode Two"]
|
||||
|
||||
Reference in New Issue
Block a user