diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts new file mode 100644 index 0000000..74b7a06 --- /dev/null +++ b/src/services/FeedCacheService.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import type { Episode } from "src/types/Episode"; +import type { PodcastFeed } from "src/types/PodcastFeed"; +import { + clearFeedCache, + getCachedEpisodes, + setCachedEpisodes, +} from "./FeedCacheService"; + +const testFeed: PodcastFeed = { + title: "Accidental Tech Podcast", + url: "https://pod.example.com/feed.xml", + artworkUrl: "https://pod.example.com/art.jpg", +}; + +function createEpisode(number: number): Episode { + return { + title: `Episode ${number}`, + streamUrl: `https://pod.example.com/ep-${number}.mp3`, + url: `https://pod.example.com/ep-${number}`, + description: `Description for episode ${number}`, + content: `

Episode ${number}

`, + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date(`2024-01-${String((number % 28) + 1).padStart(2, "0")}T00:00:00.000Z`), + }; +} + +describe("FeedCacheService", () => { + beforeEach(() => { + clearFeedCache(); + }); + + test("persists at most 75 episodes per feed (#124 cap)", () => { + const episodes = Array.from({ length: 100 }, (_, index) => + createEpisode(index + 1), + ); + + setCachedEpisodes(testFeed, episodes); + + const cached = getCachedEpisodes(testFeed); + expect(cached).toHaveLength(75); + expect(cached?.[0]?.title).toBe("Episode 1"); + expect(cached?.[74]?.title).toBe("Episode 75"); + expect(cached?.some((episode) => episode.title === "Episode 100")).toBe( + false, + ); + }); + + test("returns persisted episodes within TTL", () => { + const episodes = [createEpisode(1), createEpisode(2)]; + + setCachedEpisodes(testFeed, episodes); + + expect(getCachedEpisodes(testFeed)).toEqual(episodes); + }); +}); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 3e04885..2654539 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -10,6 +10,10 @@ import { } from "vitest"; import createPodcastNote from "src/createPodcastNote"; +import { + clearFeedCache, + setCachedEpisodes, +} from "src/services/FeedCacheService"; import { currentEpisode, episodeCache, @@ -67,6 +71,43 @@ function resetStores() { viewState.set(ViewState.PodcastGrid); currentEpisode.update(() => undefined as unknown as Episode); plugin.set(undefined as never); + clearFeedCache(); +} + +function createNumberedEpisode(number: number): Episode { + return { + title: `Episode ${number}`, + streamUrl: `https://pod.example.com/ep-${number}.mp3`, + url: `https://pod.example.com/ep-${number}`, + description: `Description for episode ${number}`, + content: `

Episode ${number}

`, + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date( + `2024-${String((number % 12) + 1).padStart(2, "0")}-15T00:00:00.000Z`, + ), + }; +} + +function createTruncatedFeedCache(): Episode[] { + return Array.from({ length: 75 }, (_, index) => + createNumberedEpisode(622 + index), + ); +} + +function createFullFeed(): Episode[] { + return [createNumberedEpisode(100), ...createTruncatedFeedCache()]; +} + +function enableFeedCache() { + plugin.set({ + settings: { + feedCache: { + enabled: true, + ttlHours: 6, + }, + }, + } as never); } beforeEach(() => { @@ -326,3 +367,199 @@ describe("PodcastView integration flow", () => { expect(screen.queryByText("Already Finished")).not.toBeInTheDocument(); }); }); + +describe("issue #174 feed cache cap regression", () => { + const oldEpisode = createNumberedEpisode(100); + const truncatedCache = createTruncatedFeedCache(); + const fullFeed = createFullFeed(); + + beforeEach(() => { + enableFeedCache(); + episodeCache.set({ [testFeed.title]: truncatedCache }); + setCachedEpisodes(testFeed, truncatedCache); + mockGetEpisodes.mockResolvedValue(fullFeed); + }); + + test("opening a show bypasses the truncated cache and loads older episodes", async () => { + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(get(episodeCache)[testFeed.title]).toHaveLength(76); + expect( + screen.queryByText(createNumberedEpisode(621).title), + ).not.toBeInTheDocument(); + }); + + test("played view bypasses the truncated cache for older finished episodes", async () => { + hidePlayedEpisodes.set(true); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Played")).toBeInTheDocument(); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect( + screen.queryByText("Unavailable in current feeds"), + ).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByText(oldEpisode.title)); + expect(get(currentEpisode)).toMatchObject({ title: oldEpisode.title }); + expect(get(viewState)).toBe(ViewState.Player); + }); + + test("latest episodes background fetch still uses the truncated cache", async () => { + viewState.set(ViewState.EpisodeList); + + render(PodcastView); + + await waitFor(() => + expect(get(episodeCache)[testFeed.title]).toHaveLength(75), + ); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + expect( + screen.queryByText(oldEpisode.title), + ).not.toBeInTheDocument(); + expect( + await screen.findByText(createNumberedEpisode(622).title), + ).toBeInTheDocument(); + }); + + test("reopening a show reuses full in-memory cache without refetching", async () => { + render(PodcastView); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: /podcast grid/i }), + ); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + + await fireEvent.click(feedImage); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); + + test("falls back to truncated cache when a full feed fetch fails", async () => { + mockGetEpisodes.mockRejectedValue(new Error("network unavailable")); + + render(PodcastView); + + const feedImage = await screen.findByAltText(testFeed.title); + await fireEvent.click(feedImage); + + expect( + await screen.findByText(createNumberedEpisode(622).title), + ).toBeInTheDocument(); + expect(screen.queryByText(oldEpisode.title)).not.toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); + + test("played view only fetches feeds with finished played episodes", async () => { + const secondFeed: PodcastFeed = { + title: "Second Podcast", + url: "https://pod.example.com/feed-two.xml", + artworkUrl: "https://pod.example.com/art-two.jpg", + }; + + hidePlayedEpisodes.set(true); + savedFeeds.set({ + [testFeed.title]: testFeed, + [secondFeed.title]: secondFeed, + }); + episodeCache.set({ + [testFeed.title]: truncatedCache, + [secondFeed.title]: [createNumberedEpisode(900)], + }); + setCachedEpisodes(secondFeed, [createNumberedEpisode(900)]); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + }); + + test("reopening played view reuses full in-memory cache without refetching", async () => { + hidePlayedEpisodes.set(true); + playedEpisodes.set({ + [`${testFeed.title}::${oldEpisode.title}`]: { + title: oldEpisode.title, + podcastName: testFeed.title, + time: 3600, + duration: 3600, + finished: true, + }, + }); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: /latest episodes/i }), + ); + await fireEvent.click(playedCard); + + expect( + await screen.findByText(oldEpisode.title), + ).toBeInTheDocument(); + expect(mockGetEpisodes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 3cb5c8c..9ab51d8 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -139,16 +139,38 @@ }; }); + type EpisodeFetchStrategy = "cached" | "full" | "network"; + + function getFeedCacheTtlMs(): number { + const feedCacheSettings = get(plugin)?.settings?.feedCache; + return Math.max(1, feedCacheSettings?.ttlHours ?? 6) * 60 * 60 * 1000; + } + + function isFeedCacheEnabled(): boolean { + return get(plugin)?.settings?.feedCache?.enabled !== false; + } + + function hasFullInMemoryFeed( + inMemoryEpisodes: Episode[] | undefined, + persistedEpisodes: Episode[] | null, + ): boolean { + if (!inMemoryEpisodes?.length) { + return false; + } + + if (!persistedEpisodes?.length) { + return true; + } + + return inMemoryEpisodes.length > persistedEpisodes.length; + } + async function fetchEpisodes( feed: PodcastFeed, useCache: boolean = true, ): Promise { - - const pluginInstance = get(plugin); - const feedCacheSettings = pluginInstance?.settings?.feedCache; - const cacheEnabled = feedCacheSettings?.enabled !== false; - const cacheTtlMs = - Math.max(1, feedCacheSettings?.ttlHours ?? 6) * 60 * 60 * 1000; + const cacheEnabled = isFeedCacheEnabled(); + const cacheTtlMs = getFeedCacheTtlMs(); const currentCache = get(episodeCache); const cachedEpisodesInFeed = currentCache[feed.title]; @@ -190,8 +212,71 @@ error, ); const downloaded = get(downloadedEpisodes); - return downloaded[feed.title] || []; + if (downloaded[feed.title]?.length) { + return downloaded[feed.title]; + } + + if (!useCache) { + if (cachedEpisodesInFeed?.length) { + return cachedEpisodesInFeed; + } + + if (cacheEnabled) { + const persistedEpisodes = getCachedEpisodes( + feed, + cacheTtlMs, + ); + if (persistedEpisodes?.length) { + episodeCache.update((cache) => ({ + ...cache, + [feed.title]: persistedEpisodes, + })); + return persistedEpisodes; + } + } + } + + return []; + } + } + + function getFeedsWithPlayedEpisodes(): PodcastFeed[] { + const playedPodcastNames = new Set( + getFinishedPlayedEpisodeRecords(get(playedEpisodes)).map( + ({ episode }) => episode.podcastName, + ), + ); + + return feeds.filter((feed) => playedPodcastNames.has(feed.title)); + } + + async function fetchFullEpisodes(feed: PodcastFeed): Promise { + const cacheEnabled = isFeedCacheEnabled(); + const persistedEpisodes = cacheEnabled + ? getCachedEpisodes(feed, getFeedCacheTtlMs()) + : null; + const inMemoryEpisodes = get(episodeCache)[feed.title]; + + if (hasFullInMemoryFeed(inMemoryEpisodes, persistedEpisodes)) { + return inMemoryEpisodes; + } + + return fetchEpisodes(feed, false); + } + + async function fetchEpisodesByStrategy( + feed: PodcastFeed, + strategy: EpisodeFetchStrategy = "cached", + ): Promise { + if (strategy === "network") { + return fetchEpisodes(feed, false); + } + + if (strategy === "full") { + return fetchFullEpisodes(feed); } + + return fetchEpisodes(feed, true); } function getPlayedPlaylist(): Playlist { @@ -301,7 +386,7 @@ function fetchEpisodesInAllFeeds( feedsToSearch: PodcastFeed[], - useCache: boolean = true, + strategy: EpisodeFetchStrategy = "cached", ): Promise { if (!feedsToSearch.length) return Promise.resolve(); @@ -310,7 +395,7 @@ setFeedLoading(feed.title, true); try { - await fetchEpisodes(feed, useCache); + await fetchEpisodesByStrategy(feed, strategy); } finally { setFeedLoading(feed.title, false); } @@ -332,7 +417,7 @@ setFeedLoading(feed.title, true); try { - const episodes = await fetchEpisodes(feed); + const episodes = await fetchFullEpisodes(feed); displayedEpisodes = currentSearchQuery ? searchEpisodes(currentSearchQuery, episodes) : episodes; @@ -377,7 +462,10 @@ async function handleClickRefresh() { if (isShowingPlayedEpisodes) { - await fetchEpisodesInAllFeeds(feeds, false); + await fetchEpisodesInAllFeeds( + getFeedsWithPlayedEpisodes(), + "network", + ); updateDisplayedPlayedEpisodesIfSelected(); return; } @@ -387,7 +475,10 @@ setFeedLoading(selectedFeed.title, true); try { - const episodes = await fetchEpisodes(selectedFeed, false); + const episodes = await fetchEpisodesByStrategy( + selectedFeed, + "network", + ); displayedEpisodeEntries = null; displayedEpisodes = currentSearchQuery ? searchEpisodes(currentSearchQuery, episodes) @@ -437,7 +528,10 @@ displayedEpisodeEntries = []; viewState.set(ViewState.EpisodeList); - void fetchEpisodesInAllFeeds(feeds).then(() => { + void fetchEpisodesInAllFeeds( + getFeedsWithPlayedEpisodes(), + "full", + ).then(() => { updateDisplayedPlayedEpisodesIfSelected(); }); return;