fix: fetch more respects episode cache mode
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).\nCount: keep the N most recent episodes (see Cache Count below).\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: 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.`,
|
||||||
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,
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ import { useDownloadStore } from "./download";
|
|||||||
import { useAppStore } from "./app";
|
import { useAppStore } from "./app";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
||||||
|
* by FETCH_MORE_WINDOW_DAYS instead). */
|
||||||
const MAX_EPISODES_REFRESH = 50;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
|
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||||
|
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||||
|
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||||
|
|
||||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||||
* the background refresh loop. */
|
* the background refresh loop. */
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
@@ -127,6 +132,13 @@ function episodeKeepFn(prefs: {
|
|||||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||||
|
* (Infinity) so they can never be excluded by a date cutoff. */
|
||||||
|
const epTs = (ep: Episode): number => {
|
||||||
|
const t = ep.pubDate?.getTime();
|
||||||
|
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
||||||
|
};
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -864,10 +876,36 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||||
const newCount = Math.min(
|
const prefs = useAppStore().state().preferences;
|
||||||
currentCount + MAX_EPISODES_REFRESH,
|
|
||||||
cached.length,
|
// 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.
|
||||||
|
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
|
||||||
|
let newCount: number;
|
||||||
|
if (prefs.episodeCacheMode === "date") {
|
||||||
|
const ref = cached[Math.max(0, currentCount - 1)];
|
||||||
|
const refTs = ref ? epTs(ref) : Infinity;
|
||||||
|
if (Number.isFinite(refTs)) {
|
||||||
|
const cutoff = refTs - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||||
|
newCount = currentCount;
|
||||||
|
while (
|
||||||
|
newCount < cached.length &&
|
||||||
|
epTs(cached[newCount]) >= cutoff
|
||||||
|
) {
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newCount = currentCount;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
newCount = Math.min(newCount, cached.length);
|
||||||
|
|
||||||
if (newCount <= currentCount) return; // nothing more to load
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ deliverables:
|
|||||||
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
||||||
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
||||||
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||||
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by a `FETCH_MORE_WINDOW_DAYS` (14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixed `MAX_EPISODES_REFRESH` (50) chunk. `hasMoreEpisodes` still compares `episodeLoadCount < cached.length`.
|
||||||
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -57,7 +57,8 @@ tests:
|
|||||||
- input arrays not mutated.
|
- input arrays not mutated.
|
||||||
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
||||||
- 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 31-day-old episode is neither visible nor cached.
|
- 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.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||||
* page's row.
|
* page's row.
|
||||||
*
|
*
|
||||||
* addFeed caches every episode inside the lifecycle window (the last
|
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
|
||||||
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
|
||||||
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
* (Date-mode fetch-more steps by a two-week window instead — that contract
|
||||||
* reports when the cache holds more than the loaded window;
|
* is pinned in feed-volatile-merge.test.ts.) This pins:
|
||||||
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
|
||||||
* chunks until it is exhausted. This pins:
|
|
||||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||||
@@ -26,6 +24,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
|
|||||||
process.env.XDG_CONFIG_HOME = configHome;
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import { useAppStore } from "../src/stores/app";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
const HOUR = 3600 * 1000;
|
const HOUR = 3600 * 1000;
|
||||||
@@ -73,6 +72,11 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
// Chunk-based stepping is count-mode behavior (see header comment).
|
||||||
|
useAppStore().updatePreferences({
|
||||||
|
episodeCacheMode: "count",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
});
|
||||||
server = Bun.serve({
|
server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
@@ -89,6 +93,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||||
|
useAppStore().updatePreferences({ episodeCacheMode: "date" });
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
for (const id of addedFeedIds) store.removeFeed(id);
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
server?.stop(true);
|
server?.stop(true);
|
||||||
|
|||||||
@@ -296,6 +296,41 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
|
|||||||
|
|
||||||
// ── count mode ────────────────────────────────────────────────────────────
|
// ── count mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("date mode: fetch-more steps by a two-week window, not a count", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
// 30 episodes at 3-day spacing span 87 days. The 60-day cache window
|
||||||
|
// holds the first 21 (subscribe shows 20); fetch-more then reveals the
|
||||||
|
// next 2-week band per press — 3-day cadence → ~4 episodes per band —
|
||||||
|
// NOT a fixed 50-episode chunk (which would load all 30 at once).
|
||||||
|
servedEpisodes = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
title: `Ep ${30 - i}`,
|
||||||
|
date: new Date(now - i * 3 * DAY).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-step.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(20);
|
||||||
|
|
||||||
|
// Press 1: oldest loaded is 57d old → cutoff 71d → i=20..23 (60–69d).
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(24);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
|
||||||
|
// Press 2: oldest loaded is 69d old → cutoff 83d → i=24..27 (72–81d).
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(28);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
|
||||||
|
// Press 3: oldest loaded is 81d old → cutoff 95d → i=28..29 (84–87d).
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(30);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user