fix: fetch more respects episode cache mode
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).\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) => {
|
||||
const idx = CACHE_MODE_LABELS.findIndex(
|
||||
(s) => s.value === prefs().episodeCacheMode,
|
||||
|
||||
@@ -26,12 +26,17 @@ import { useDownloadStore } from "./download";
|
||||
import { useAppStore } from "./app";
|
||||
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;
|
||||
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
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
|
||||
* the background refresh loop. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
@@ -127,6 +132,13 @@ function episodeKeepFn(prefs: {
|
||||
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). */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
const prefs = useAppStore().state().preferences;
|
||||
@@ -864,10 +876,36 @@ function createFeedStore() {
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
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.
|
||||
// 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
- 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`).
|
||||
- `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.
|
||||
|
||||
steps:
|
||||
@@ -57,7 +57,8 @@ tests:
|
||||
- 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):
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||
* page's row.
|
||||
*
|
||||
* addFeed caches every episode inside the lifecycle window (the last
|
||||
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
||||
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
||||
* reports when the cache holds more than the loaded window;
|
||||
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
||||
* chunks until it is exhausted. This pins:
|
||||
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
|
||||
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
|
||||
* (Date-mode fetch-more steps by a two-week window instead — that contract
|
||||
* is pinned in feed-volatile-merge.test.ts.) This pins:
|
||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||
* 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;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import { useAppStore } from "../src/stores/app";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
@@ -73,6 +72,11 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// Chunk-based stepping is count-mode behavior (see header comment).
|
||||
useAppStore().updatePreferences({
|
||||
episodeCacheMode: "count",
|
||||
episodeCacheCount: 25,
|
||||
});
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
@@ -89,6 +93,7 @@ beforeAll(() => {
|
||||
|
||||
afterAll(() => {
|
||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||
useAppStore().updatePreferences({ episodeCacheMode: "date" });
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
|
||||
@@ -296,6 +296,41 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
|
||||
|
||||
// ── 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 () => {
|
||||
const store = useFeedStore();
|
||||
const app = useAppStore();
|
||||
|
||||
Reference in New Issue
Block a user