feat(feed): signature-aware volatile merge with date-banded fetch-more

This commit is contained in:
2026-08-13 17:46:27 -04:00
parent 42c48e59fb
commit 878d1e01ab
9 changed files with 406 additions and 57 deletions

View File

@@ -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,

View File

@@ -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 6074d 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,

View File

@@ -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))
} }

View File

@@ -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.

View File

@@ -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(

View File

@@ -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",

View File

@@ -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 () => {

View File

@@ -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 0199d). Feed B: 200 at
// 1-day spacing shifted 200 days older (ages 200399d) — 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" });
});

View File

@@ -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"]