From c5337a9800b5e3e42926c13a79d24737e0ab9739 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:22:31 +0200 Subject: [PATCH 1/5] feat(episodes): add episodeListLimit setting to control Latest Episodes length (#114) --- docs/docs/podcasts.md | 13 +++ src/constants.ts | 16 +++ src/main.ts | 5 + src/store/index.test.ts | 132 ++++++++++++++++++++++++- src/store/index.ts | 95 +++++++++++++++--- src/types/IPodNotesSettings.ts | 6 ++ src/ui/settings/PodNotesSettingsTab.ts | 38 +++++++ 7 files changed, 293 insertions(+), 12 deletions(-) diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md index e55aafd2..6cac993b 100644 --- a/docs/docs/podcasts.md +++ b/docs/docs/podcasts.md @@ -30,6 +30,19 @@ If you have a podcast selected, the episode list will show all episodes of that And lastly, if you select a playlist, the episode list shows all episodes in that playlist. +### How many latest episodes are shown + +The "Latest Episodes" list keeps a limited number of the most recent episodes +from each saved podcast. This same set is what the search box filters when no +podcast is selected, so episodes older than the limit will not appear in those +search results. + +You can change this with the **Latest episodes per podcast** setting in the +PodNotes settings tab. It defaults to 10. Raise it to surface more of each +feed's history in the Latest Episodes list and to search further back; lower it +for a shorter list. Selecting an individual podcast still shows all of that +podcast's episodes regardless of this setting. + ## Context menu You can right-click (desktop) or long-press (mobile) on an episode in the episode list to open the context menu. diff --git a/src/constants.ts b/src/constants.ts index 74883fce..5bf59245 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,21 @@ import type { Playlist } from "./types/Playlist"; export const VIEW_TYPE = "podcast_player_view"; +/** + * How many of each feed's most recent episodes are surfaced in the aggregated + * "Latest Episodes" list (and therefore searchable from it). The default of 10 + * preserves the historical behaviour; users who want to search further back + * through each feed can raise it (issue #114). + */ +export const DEFAULT_EPISODE_LIST_LIMIT = 10; + +/** + * Upper bound for {@link DEFAULT_EPISODE_LIST_LIMIT}. Keeps an accidental huge + * value (e.g. a fat-fingered settings entry) from materialising an unbounded + * latest-episodes list. + */ +export const MAX_EPISODE_LIST_LIMIT = 1000; + type PlaylistSettings = Pick< Playlist, "icon" | "name" | "shouldEpisodeRemoveAfterPlay" | "shouldRepeat" @@ -42,6 +57,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { defaultPlaybackRate: 1, defaultVolume: 1, hidePlayedEpisodes: false, + episodeListLimit: DEFAULT_EPISODE_LIST_LIMIT, playedEpisodes: {}, favorites: { ...FAVORITES_SETTINGS, diff --git a/src/main.ts b/src/main.ts index faa2d127..b5309341 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { currentEpisode, downloadedEpisodes, + episodeListLimit, favorites, localFiles, playedEpisodes, @@ -8,6 +9,7 @@ import { queue, savedFeeds, hidePlayedEpisodes, + sanitizeEpisodeListLimit, volume, } from "src/store"; import { Plugin, type WorkspaceLeaf } from "obsidian"; @@ -100,6 +102,9 @@ export default class PodNotes extends Plugin implements IPodNotes { currentEpisode.set(this.settings.currentEpisode); } hidePlayedEpisodes.set(this.settings.hidePlayedEpisodes); + episodeListLimit.set( + sanitizeEpisodeListLimit(this.settings.episodeListLimit), + ); volume.set( Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)), ); diff --git a/src/store/index.test.ts b/src/store/index.test.ts index 90af4177..3b07ba14 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -5,7 +5,12 @@ import type { Episode } from "src/types/Episode"; import type { IPodNotes } from "src/types/IPodNotes"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; -import { LOCAL_FILES_SETTINGS, QUEUE_SETTINGS } from "src/constants"; +import { + DEFAULT_EPISODE_LIST_LIMIT, + LOCAL_FILES_SETTINGS, + MAX_EPISODE_LIST_LIMIT, + QUEUE_SETTINGS, +} from "src/constants"; import { QueueController } from "src/store_controllers/QueueController"; import { currentEpisode, @@ -13,11 +18,15 @@ import { dedupeEpisodesByTitle, downloadedEpisodes, duration, + episodeCache, + episodeListLimit, + latestEpisodes, localFiles, playedEpisodes, plugin, queue, reorderEpisodes, + sanitizeEpisodeListLimit, } from "./index"; const episode: Episode = { @@ -508,6 +517,127 @@ describe("queue automation toggle (issue #108)", () => { }); }); +describe("sanitizeEpisodeListLimit (issue #114)", () => { + test("keeps valid positive integers", () => { + expect(sanitizeEpisodeListLimit(15)).toBe(15); + expect(sanitizeEpisodeListLimit("25")).toBe(25); + }); + + test("floors fractional values", () => { + expect(sanitizeEpisodeListLimit(7.9)).toBe(7); + }); + + test("falls back to the default for missing/invalid/non-positive values", () => { + expect(sanitizeEpisodeListLimit(0)).toBe(DEFAULT_EPISODE_LIST_LIMIT); + expect(sanitizeEpisodeListLimit(-5)).toBe(DEFAULT_EPISODE_LIST_LIMIT); + expect(sanitizeEpisodeListLimit(Number.NaN)).toBe( + DEFAULT_EPISODE_LIST_LIMIT, + ); + expect(sanitizeEpisodeListLimit(undefined)).toBe( + DEFAULT_EPISODE_LIST_LIMIT, + ); + expect(sanitizeEpisodeListLimit("nope")).toBe(DEFAULT_EPISODE_LIST_LIMIT); + }); + + test("clamps to the maximum", () => { + expect(sanitizeEpisodeListLimit(10 ** 9)).toBe(MAX_EPISODE_LIST_LIMIT); + }); +}); + +describe("latestEpisodes respects episodeListLimit (issue #114)", () => { + // Newest-first like a real RSS feed; the store re-sorts by date anyway. + function feedEpisodes(podcastName: string, count: number): Episode[] { + return Array.from({ length: count }, (_, i) => { + const ordinal = count - i; + return { + title: `${podcastName} #${ordinal}`, + streamUrl: `https://example.com/${podcastName}/${ordinal}.mp3`, + url: `https://example.com/${podcastName}/${ordinal}`, + description: "", + content: "", + podcastName, + episodeDate: new Date(2020, 0, ordinal), + } satisfies Episode; + }); + } + + function trackLatest(): { value: () => Episode[]; stop: () => void } { + let current: Episode[] = []; + const unsubscribe = latestEpisodes.subscribe((value) => { + current = value; + }); + return { value: () => current, stop: unsubscribe }; + } + + beforeEach(() => { + episodeCache.set({}); + episodeListLimit.set(DEFAULT_EPISODE_LIST_LIMIT); + }); + + afterEach(() => { + episodeCache.set({}); + episodeListLimit.set(DEFAULT_EPISODE_LIST_LIMIT); + }); + + test("defaults to the latest 10 episodes per feed", () => { + const tracker = trackLatest(); + + episodeCache.set({ "Show A": feedEpisodes("Show A", 25) }); + + expect(tracker.value()).toHaveLength(DEFAULT_EPISODE_LIST_LIMIT); + tracker.stop(); + }); + + test("raising the limit surfaces more episodes per feed", () => { + const tracker = trackLatest(); + episodeCache.set({ "Show A": feedEpisodes("Show A", 25) }); + expect(tracker.value()).toHaveLength(10); + + episodeListLimit.set(20); + + expect(tracker.value()).toHaveLength(20); + expect(tracker.value()[0]?.title).toBe("Show A #25"); + tracker.stop(); + }); + + test("lowering the limit shrinks the list", () => { + const tracker = trackLatest(); + episodeCache.set({ "Show A": feedEpisodes("Show A", 25) }); + + episodeListLimit.set(3); + + expect(tracker.value()).toHaveLength(3); + tracker.stop(); + }); + + test("invalid limits fall back to the default", () => { + const tracker = trackLatest(); + episodeCache.set({ "Show A": feedEpisodes("Show A", 25) }); + + episodeListLimit.set(20); + expect(tracker.value()).toHaveLength(20); + + episodeListLimit.set(0); + + expect(tracker.value()).toHaveLength(DEFAULT_EPISODE_LIST_LIMIT); + tracker.stop(); + }); + + test("the merged latest list scales the per-feed limit by feed count", () => { + const tracker = trackLatest(); + episodeListLimit.set(5); + + episodeCache.set({ + "Show A": feedEpisodes("Show A", 25), + "Show B": feedEpisodes("Show B", 25), + }); + + // 5 per feed * 2 feeds = 10. + expect(tracker.value()).toHaveLength(10); + tracker.stop(); + }); +}); + describe("currentEpisode.set finished guard (issue #94)", () => { beforeEach(() => { playedEpisodes.set({}); diff --git a/src/store/index.ts b/src/store/index.ts index ca881ad2..b7897cee 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,7 +8,11 @@ import { ViewState } from "src/types/ViewState"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import { TFile } from "obsidian"; import type { LocalEpisode } from "src/types/LocalEpisode"; -import { LOCAL_FILES_SETTINGS } from "src/constants"; +import { + DEFAULT_EPISODE_LIST_LIMIT, + LOCAL_FILES_SETTINGS, + MAX_EPISODE_LIST_LIMIT, +} from "src/constants"; import { getEpisodeKey } from "src/utility/episodeKey"; import { getPlayedEpisode, @@ -183,7 +187,26 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({}); export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({}); -const LATEST_EPISODES_PER_FEED = 10; +/** + * How many of each feed's most recent episodes the aggregated "Latest Episodes" + * list keeps. Backed by the `episodeListLimit` setting; `main.ts` seeds it from + * the loaded settings and the settings tab updates it live (issue #114). + */ +export const episodeListLimit = writable(DEFAULT_EPISODE_LIST_LIMIT); + +/** + * Coerce a stored/raw limit into a usable positive integer, falling back to the + * default for missing/NaN/zero/negative values and clamping the upper bound so a + * stray huge number can't materialise an unbounded list. + */ +export function sanitizeEpisodeListLimit(value: unknown): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 1) { + return DEFAULT_EPISODE_LIST_LIMIT; + } + + return Math.min(Math.floor(numeric), MAX_EPISODE_LIST_LIMIT); +} type LatestEpisodesByFeed = Map; type FeedEpisodeSources = Map; @@ -194,11 +217,14 @@ function getEpisodeTimestamp(episode?: Episode): number { return Number(episode.episodeDate); } -function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] { +function getLatestEpisodesForFeed( + episodes: Episode[], + perFeedLimit: number, +): Episode[] { if (!episodes?.length) return []; return episodes - .slice(0, LATEST_EPISODES_PER_FEED) + .slice(0, perFeedLimit) .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)); } @@ -283,14 +309,14 @@ export const latestEpisodes = readable([], (set) => { let latestByFeed: LatestEpisodesByFeed = new Map(); let feedSources: FeedEpisodeSources = new Map(); let mergedLatest: Episode[] = []; + let perFeedLimit = sanitizeEpisodeListLimit(get(episodeListLimit)); - const unsubscribe = episodeCache.subscribe((cache) => { + // Incremental update for the common case (a single feed's cache changing): + // reuse each feed's already-computed slice and only re-merge what moved. + function applyCache(cache: { [podcastName: string]: Episode[] }) { const cacheEntries = Object.entries(cache); const feedCount = cacheEntries.length; - const latestLimit = Math.max( - 1, - LATEST_EPISODES_PER_FEED * Math.max(feedCount, 1), - ); + const latestLimit = Math.max(1, perFeedLimit * Math.max(feedCount, 1)); let changed = false; let nextMerged = mergedLatest; @@ -305,7 +331,7 @@ export const latestEpisodes = readable([], (set) => { const nextLatestForFeed = previousSource === episodes && previousLatest ? previousLatest - : getLatestEpisodesForFeed(episodes); + : getLatestEpisodesForFeed(episodes, perFeedLimit); nextLatestByFeed.set(feedTitle, nextLatestForFeed); @@ -337,13 +363,60 @@ export const latestEpisodes = readable([], (set) => { mergedLatest = nextMerged; set(mergedLatest); } + } + + // Changing the per-feed limit re-slices every feed, so the incremental reuse + // above no longer holds. Drop the cached slices and rebuild from scratch; this + // only runs when the `episodeListLimit` setting changes (issue #114). + function rebuildForLimitChange() { + const cache = get(episodeCache); + const cacheEntries = Object.entries(cache); + const feedCount = cacheEntries.length; + const latestLimit = Math.max(1, perFeedLimit * Math.max(feedCount, 1)); + + const nextSources: FeedEpisodeSources = new Map(); + const nextLatestByFeed: LatestEpisodesByFeed = new Map(); + let nextMerged: Episode[] = []; + + for (const [feedTitle, episodes] of cacheEntries) { + const nextLatestForFeed = getLatestEpisodesForFeed( + episodes, + perFeedLimit, + ); + nextSources.set(feedTitle, episodes); + nextLatestByFeed.set(feedTitle, nextLatestForFeed); + + for (const episode of nextLatestForFeed) { + nextMerged = insertEpisodeSorted(nextMerged, episode, latestLimit); + } + } + + feedSources = nextSources; + latestByFeed = nextLatestByFeed; + + if (!shallowEqualEpisodes(mergedLatest, nextMerged)) { + mergedLatest = nextMerged; + set(mergedLatest); + } + } + + const unsubscribeCache = episodeCache.subscribe(applyCache); + + const unsubscribeLimit = episodeListLimit.subscribe((value) => { + const nextLimit = sanitizeEpisodeListLimit(value); + // Skip the immediate-fire (same value) and any no-op writes; only a real + // change needs the full rebuild. + if (nextLimit === perFeedLimit) return; + perFeedLimit = nextLimit; + rebuildForLimitChange(); }); return () => { latestByFeed.clear(); feedSources.clear(); mergedLatest = []; - unsubscribe(); + unsubscribeLimit(); + unsubscribeCache(); }; }); diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index d8012be4..b727a892 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -11,6 +11,12 @@ export interface IPodNotesSettings { defaultPlaybackRate: number; defaultVolume: number; hidePlayedEpisodes: boolean; + /** + * How many of each saved feed's most recent episodes are surfaced in the + * aggregated "Latest Episodes" list (and searchable from it). Defaults to 10; + * raise it to look further back through each feed's history (issue #114). + */ + episodeListLimit: number; playedEpisodes: { [episodeName: string]: PlayedEpisode }; skipBackwardLength: number; skipForwardLength: number; diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index a7267f29..05e97e0c 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -19,15 +19,21 @@ import { } from "../../TemplateEngine"; import { episodeCache, + episodeListLimit, favorites, hidePlayedEpisodes, localFiles, playlists, plugin, queue, + sanitizeEpisodeListLimit, savedFeeds, volume, } from "src/store/index"; +import { + DEFAULT_EPISODE_LIST_LIMIT, + MAX_EPISODE_LIST_LIMIT, +} from "src/constants"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; @@ -86,6 +92,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }); this.addQueueSettings(settingsContainer); + this.addEpisodeListSettings(settingsContainer); this.addDefaultPlaybackRateSetting(settingsContainer); this.addDefaultVolumeSetting(settingsContainer); this.addSkipLengthSettings(settingsContainer); @@ -128,6 +135,37 @@ export class PodNotesSettingsTab extends PluginSettingTab { ); } + private addEpisodeListSettings(container: HTMLElement): void { + new Setting(container) + .setName("Latest episodes per podcast") + .setDesc( + `How many of each podcast's most recent episodes appear in the Latest Episodes list, and how far back its search reaches. Raise this to find older episodes (1-${MAX_EPISODE_LIST_LIMIT}; default ${DEFAULT_EPISODE_LIST_LIMIT}).`, + ) + .addText((textComponent) => { + textComponent.inputEl.type = "number"; + textComponent.inputEl.min = "1"; + textComponent.inputEl.max = `${MAX_EPISODE_LIST_LIMIT}`; + textComponent + .setValue( + `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, + ) + .setPlaceholder(`${DEFAULT_EPISODE_LIST_LIMIT}`) + .onChange(async (value) => { + const sanitized = sanitizeEpisodeListLimit(value); + this.plugin.settings.episodeListLimit = sanitized; + episodeListLimit.set(sanitized); + await this.plugin.saveSettings(); + }); + // Reflect the clamped/sanitized value back once the user finishes + // editing, so an out-of-range or empty entry doesn't linger in the box. + textComponent.inputEl.addEventListener("blur", () => { + textComponent.setValue( + `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, + ); + }); + }); + } + private addDefaultPlaybackRateSetting(container: HTMLElement): void { new Setting(container) .setName("Default Playback Rate") From 1550d542e1b21647143749909855632fe2cbfac3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:24:24 +0200 Subject: [PATCH 2/5] test(e2e): seed episodeListLimit in provision vault DEFAULT_PODNOTES_DATA (#114) --- scripts/provision-obsidian-e2e-vault.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/provision-obsidian-e2e-vault.mjs b/scripts/provision-obsidian-e2e-vault.mjs index 7666f41a..38e72dd6 100644 --- a/scripts/provision-obsidian-e2e-vault.mjs +++ b/scripts/provision-obsidian-e2e-vault.mjs @@ -27,6 +27,7 @@ export const DEFAULT_PODNOTES_DATA = { defaultPlaybackRate: 1, defaultVolume: 1, hidePlayedEpisodes: false, + episodeListLimit: 10, playedEpisodes: {}, favorites: { icon: "lucide-star", From 609950c3e9eb6e73a5612875dda1a0f196ec00a3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:50:52 +0200 Subject: [PATCH 3/5] fix(episodes): harden episodeListLimit per review (cache cap, sort order, import, load sanitize) - Align MAX_EPISODE_LIST_LIMIT with the feed cache's per-feed retention (75) so a chosen limit is always serveable from a warm cache; selecting a podcast still shows its full archive. - Sort each feed by date before truncating so the per-feed limit keeps the NEWEST episodes even when the feed/cache is not newest-first. - Rehydrate the episodeListLimit store on settings import (was only persisted). - Sanitize episodeListLimit in loadSettings so a malformed persisted value is repaired in the settings object, not just clamped at runtime. - Settings input: don't clobber the saved limit on empty/mid-edit input and skip redundant saves. - Rebuild the latest list with a single sort+slice on limit change. Refs #114 --- docs/docs/podcasts.md | 9 +++--- src/constants.ts | 11 ++++--- src/main.ts | 12 ++++++-- src/services/FeedCacheService.ts | 3 ++ src/store/index.test.ts | 40 +++++++++++++++++++++++--- src/store/index.ts | 24 ++++++++++------ src/ui/settings/PodNotesSettingsTab.ts | 12 +++++++- 7 files changed, 87 insertions(+), 24 deletions(-) diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md index 6cac993b..2734c3a6 100644 --- a/docs/docs/podcasts.md +++ b/docs/docs/podcasts.md @@ -38,10 +38,11 @@ podcast is selected, so episodes older than the limit will not appear in those search results. You can change this with the **Latest episodes per podcast** setting in the -PodNotes settings tab. It defaults to 10. Raise it to surface more of each -feed's history in the Latest Episodes list and to search further back; lower it -for a shorter list. Selecting an individual podcast still shows all of that -podcast's episodes regardless of this setting. +PodNotes settings tab. It defaults to 10 and can be raised up to 75. Raise it to +surface more of each feed's history in the Latest Episodes list and to search +further back; lower it for a shorter list. Selecting an individual podcast still +shows all of that podcast's episodes regardless of this setting, so the full +back catalogue of any one show always remains searchable from its own view. ## Context menu You can right-click (desktop) or long-press (mobile) on an episode in the episode list to open the context menu. diff --git a/src/constants.ts b/src/constants.ts index 5bf59245..255dfd02 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,11 +12,14 @@ export const VIEW_TYPE = "podcast_player_view"; export const DEFAULT_EPISODE_LIST_LIMIT = 10; /** - * Upper bound for {@link DEFAULT_EPISODE_LIST_LIMIT}. Keeps an accidental huge - * value (e.g. a fat-fingered settings entry) from materialising an unbounded - * latest-episodes list. + * Upper bound for {@link DEFAULT_EPISODE_LIST_LIMIT}. Kept in lockstep with the + * feed cache's `MAX_EPISODES_PER_FEED` (see src/services/FeedCacheService.ts): + * on a warm start the Latest Episodes list is rebuilt from the persisted cache, + * which retains at most that many episodes per feed, so a limit larger than the + * cap could never actually be served. Selecting an individual podcast still + * shows that feed's full episode list, unbounded by this setting. */ -export const MAX_EPISODE_LIST_LIMIT = 1000; +export const MAX_EPISODE_LIST_LIMIT = 75; type PlaylistSettings = Pick< Playlist, diff --git a/src/main.ts b/src/main.ts index b5309341..d7d0f5b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -102,9 +102,9 @@ export default class PodNotes extends Plugin implements IPodNotes { currentEpisode.set(this.settings.currentEpisode); } hidePlayedEpisodes.set(this.settings.hidePlayedEpisodes); - episodeListLimit.set( - sanitizeEpisodeListLimit(this.settings.episodeListLimit), - ); + // loadSettings() already sanitized this, so the store stays in sync with + // the (repaired) persisted value. + episodeListLimit.set(this.settings.episodeListLimit); volume.set( Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)), ); @@ -462,6 +462,12 @@ export default class PodNotes extends Plugin implements IPodNotes { this.settings.download.path = migrateDownloadPath( this.settings.download.path, ); + // Normalise the persisted limit so a malformed value (e.g. 0 from an older + // data.json) is repaired in the settings object too, not just clamped for + // runtime behaviour, and so a later saveSettings() can't re-persist it (#114). + this.settings.episodeListLimit = sanitizeEpisodeListLimit( + this.settings.episodeListLimit, + ); } async saveSettings() { diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index 6f840258..931c5016 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -22,6 +22,9 @@ const STORAGE_KEY = "podnotes:feed-cache:v2"; // orphan ~MBs of data (which could push v2 writes over the localStorage quota). const LEGACY_STORAGE_KEYS = ["podnotes:feed-cache:v1"]; const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours. +// Keep this >= MAX_EPISODE_LIST_LIMIT (src/constants.ts): the Latest Episodes +// list is rebuilt from this persisted cache on a warm start, so a per-feed list +// limit larger than what we retain here could never be served (issue #114). const MAX_EPISODES_PER_FEED = 75; const MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB to leave room for other localStorage usage diff --git a/src/store/index.test.ts b/src/store/index.test.ts index 3b07ba14..95c08ebf 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -540,15 +540,27 @@ describe("sanitizeEpisodeListLimit (issue #114)", () => { }); test("clamps to the maximum", () => { + expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT)).toBe( + MAX_EPISODE_LIST_LIMIT, + ); + expect(sanitizeEpisodeListLimit(MAX_EPISODE_LIST_LIMIT + 1)).toBe( + MAX_EPISODE_LIST_LIMIT, + ); expect(sanitizeEpisodeListLimit(10 ** 9)).toBe(MAX_EPISODE_LIST_LIMIT); }); }); describe("latestEpisodes respects episodeListLimit (issue #114)", () => { - // Newest-first like a real RSS feed; the store re-sorts by date anyway. - function feedEpisodes(podcastName: string, count: number): Episode[] { - return Array.from({ length: count }, (_, i) => { - const ordinal = count - i; + // `ordinal` doubles as the day-of-month, so a higher ordinal is a newer + // episode. `order: "newest-first"` mimics a typical RSS feed; "oldest-first" + // stresses that the per-feed limit ranks by date before truncating. + function feedEpisodes( + podcastName: string, + count: number, + order: "newest-first" | "oldest-first" = "newest-first", + ): Episode[] { + const episodes = Array.from({ length: count }, (_, i) => { + const ordinal = i + 1; return { title: `${podcastName} #${ordinal}`, streamUrl: `https://example.com/${podcastName}/${ordinal}.mp3`, @@ -559,6 +571,7 @@ describe("latestEpisodes respects episodeListLimit (issue #114)", () => { episodeDate: new Date(2020, 0, ordinal), } satisfies Episode; }); + return order === "newest-first" ? episodes.reverse() : episodes; } function trackLatest(): { value: () => Episode[]; stop: () => void } { @@ -636,6 +649,25 @@ describe("latestEpisodes respects episodeListLimit (issue #114)", () => { expect(tracker.value()).toHaveLength(10); tracker.stop(); }); + + test("selects the newest episodes even when the feed is oldest-first", () => { + const tracker = trackLatest(); + episodeListLimit.set(3); + + // Episodes 1..25 in ascending (oldest-first) order. A naive slice-before-sort + // would keep #1..#3 (the oldest); the limit must rank by date and keep the + // newest three. + episodeCache.set({ + "Show A": feedEpisodes("Show A", 25, "oldest-first"), + }); + + expect(tracker.value().map((episode) => episode.title)).toEqual([ + "Show A #25", + "Show A #24", + "Show A #23", + ]); + tracker.stop(); + }); }); describe("currentEpisode.set finished guard (issue #94)", () => { diff --git a/src/store/index.ts b/src/store/index.ts index b7897cee..ca592651 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -223,9 +223,13 @@ function getLatestEpisodesForFeed( ): Episode[] { if (!episodes?.length) return []; - return episodes - .slice(0, perFeedLimit) - .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)); + // Sort by date first, THEN take the newest N. Slicing before sorting would + // only keep the newest episodes when the feed is already newest-first; for a + // feed (or cache) in any other order it would surface the wrong episodes, so + // the per-feed limit must rank the whole feed before truncating (issue #114). + return [...episodes] + .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)) + .slice(0, perFeedLimit); } function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean { @@ -376,7 +380,7 @@ export const latestEpisodes = readable([], (set) => { const nextSources: FeedEpisodeSources = new Map(); const nextLatestByFeed: LatestEpisodesByFeed = new Map(); - let nextMerged: Episode[] = []; + const collected: Episode[] = []; for (const [feedTitle, episodes] of cacheEntries) { const nextLatestForFeed = getLatestEpisodesForFeed( @@ -385,12 +389,16 @@ export const latestEpisodes = readable([], (set) => { ); nextSources.set(feedTitle, episodes); nextLatestByFeed.set(feedTitle, nextLatestForFeed); - - for (const episode of nextLatestForFeed) { - nextMerged = insertEpisodeSorted(nextMerged, episode, latestLimit); - } + collected.push(...nextLatestForFeed); } + // Merge once: sort the gathered per-feed slices by date and cap. A single + // sort avoids the repeated full-array copies insertEpisodeSorted would do + // per episode, keeping this user-triggered rebuild off the slow path. + const nextMerged = collected + .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)) + .slice(0, latestLimit); + feedSources = nextSources; latestByFeed = nextLatestByFeed; diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 05e97e0c..0570e6d4 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -151,7 +151,14 @@ export class PodNotesSettingsTab extends PluginSettingTab { ) .setPlaceholder(`${DEFAULT_EPISODE_LIST_LIMIT}`) .onChange(async (value) => { - const sanitized = sanitizeEpisodeListLimit(value); + // Don't commit while the field is empty or mid-edit (e.g. cleared, + // or a lone "-"): sanitizing "" would silently overwrite the saved + // limit with the default. Wait for a parseable number, and skip + // redundant saves so typing doesn't churn data.json each keystroke. + const trimmed = value.trim(); + if (trimmed === "" || !Number.isFinite(Number(trimmed))) return; + const sanitized = sanitizeEpisodeListLimit(trimmed); + if (sanitized === this.plugin.settings.episodeListLimit) return; this.plugin.settings.episodeListLimit = sanitized; episodeListLimit.set(sanitized); await this.plugin.saveSettings(); @@ -770,6 +777,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { queue.set(merged.queue); localFiles.set(merged.localFiles); hidePlayedEpisodes.set(merged.hidePlayedEpisodes); + const sanitizedLimit = sanitizeEpisodeListLimit(merged.episodeListLimit); + merged.episodeListLimit = sanitizedLimit; + episodeListLimit.set(sanitizedLimit); const importedVolume = Number.isFinite(merged.defaultVolume) ? merged.defaultVolume : 1; From 3808548358481b2449b8e803951dba2d0d45f7c0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 11:16:45 +0200 Subject: [PATCH 4/5] fix(cache): retain newest episodes per feed regardless of feed order (#114) Codex review (P2): setCachedEpisodes persisted the first 75 episodes in feed order, so an oldest-first feed cached its OLDEST episodes. After a warm restart within the cache TTL, the Latest Episodes list rebuilt from that stale set and search missed the actual newest episodes. Select the newest 75 by date while preserving original order (feeds <= the cap are untouched), so the per-feed list limit is correct for any feed ordering. --- src/services/FeedCacheService.test.ts | 51 ++++++++++++++++++++++++--- src/services/FeedCacheService.ts | 33 +++++++++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts index 11e50652..771547ba 100644 --- a/src/services/FeedCacheService.test.ts +++ b/src/services/FeedCacheService.test.ts @@ -32,20 +32,63 @@ describe("FeedCacheService", () => { clearFeedCache(); }); - test("persists at most 75 episodes per feed (#124 cap)", () => { + // `number` doubles as the day, so a higher number is a newer episode. + function datedEpisode(number: number): Episode { + return { + ...createEpisode(number), + title: `Episode ${number}`, + episodeDate: new Date(2024, 0, number), + }; + } + + test("persists at most 75 newest episodes per feed (#124 cap)", () => { + // Newest-first feed (100 -> 1); the newest 75 are episodes 100..26. const episodes = Array.from({ length: 100 }, (_, index) => - createEpisode(index + 1), + datedEpisode(100 - index), + ); + + setCachedEpisodes(testFeed, episodes); + + const cached = getCachedEpisodes(testFeed); + expect(cached).toHaveLength(75); + // Original (newest-first) order is preserved among the retained episodes. + expect(cached?.[0]?.title).toBe("Episode 100"); + expect(cached?.[74]?.title).toBe("Episode 26"); + expect(cached?.some((episode) => episode.title === "Episode 25")).toBe( + false, + ); + expect(cached?.some((episode) => episode.title === "Episode 1")).toBe( + false, + ); + }); + + test("retains the newest episodes when the feed is oldest-first (#114)", () => { + // Oldest-first feed (1 -> 100): the cache must keep the NEWEST 75 (26..100), + // not the first 75 in feed order, or a warm-cache Latest Episodes rebuild + // would surface stale episodes. + const episodes = Array.from({ length: 100 }, (_, index) => + datedEpisode(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( + true, + ); + expect(cached?.some((episode) => episode.title === "Episode 26")).toBe( + true, + ); + expect(cached?.some((episode) => episode.title === "Episode 25")).toBe( + false, + ); + expect(cached?.some((episode) => episode.title === "Episode 1")).toBe( false, ); + // Original (oldest-first) order is preserved among the retained episodes. + expect(cached?.[0]?.title).toBe("Episode 26"); + expect(cached?.[74]?.title).toBe("Episode 100"); }); test("returns persisted episodes within TTL", () => { diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index 931c5016..87e45f97 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -155,6 +155,33 @@ function serializeEpisode(episode: Episode): SerializableEpisode { }; } +function episodeTimestamp(episode: Episode): number { + if (!episode.episodeDate) return 0; + const time = new Date(episode.episodeDate).getTime(); + return Number.isNaN(time) ? 0 : time; +} + +/** + * Keep the newest `limit` episodes by date while preserving their original + * relative order. Selecting by date (not the first N in feed order) ensures an + * oldest-first feed still caches its NEWEST episodes, so a warm-cache rebuild of + * the Latest Episodes list isn't stuck on stale items (#114). Feeds at or under + * the limit are returned untouched, so ordering for the common case is unchanged. + */ +function selectNewestEpisodes(episodes: Episode[], limit: number): Episode[] { + if (episodes.length <= limit) return episodes; + + const keptIndices = new Set( + episodes + .map((episode, index) => ({ index, time: episodeTimestamp(episode) })) + .sort((a, b) => b.time - a.time) + .slice(0, limit) + .map((entry) => entry.index), + ); + + return episodes.filter((_, index) => keptIndices.has(index)); +} + function deserializeEpisode(episode: SerializableEpisode): Episode { return { ...episode, @@ -198,9 +225,9 @@ export function setCachedEpisodes(feed: PodcastFeed, episodes: Episode[]): void store[cacheKey] = { updatedAt: Date.now(), - episodes: episodes - .slice(0, MAX_EPISODES_PER_FEED) - .map(serializeEpisode), + episodes: selectNewestEpisodes(episodes, MAX_EPISODES_PER_FEED).map( + serializeEpisode, + ), }; persistCache(); From cbcc50336c1c40b70f571076f3ad88fe36f4fab8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 11:25:20 +0200 Subject: [PATCH 5/5] fix(cache): bump feed-cache key to v3 on retention-semantics change (#114) Codex review (P2): the previous commit changed which episodes the feed cache retains (newest-by-date instead of first-in-feed-order), but an unexpired v2 entry written by the old code still holds the first 75 feed items. For an oldest-first feed those are the oldest episodes, so a warm start after upgrade would keep Latest Episodes/search stuck on stale items until the TTL expired. Bump the storage key v2 -> v3 (and drop v2 as a legacy key) so the new retention applies immediately on upgrade, matching the existing v1 -> v2 precedent. --- src/services/FeedCacheService.test.ts | 16 ++++++++++++++++ src/services/FeedCacheService.ts | 22 ++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts index 771547ba..c6ca6401 100644 --- a/src/services/FeedCacheService.test.ts +++ b/src/services/FeedCacheService.test.ts @@ -128,6 +128,22 @@ describe("FeedCacheService", () => { expect(localStorage.getItem("podnotes:feed-cache:v1")).toBeNull(); }); + test("removes the superseded v2 cache key on first load (#114 retention change)", async () => { + // The v2 schema kept the first N episodes in feed order; v3 keeps the newest + // N by date. An unexpired v2 blob must be dropped on upgrade so an + // oldest-first feed doesn't keep serving stale episodes until the TTL. + localStorage.setItem( + "podnotes:feed-cache:v2", + JSON.stringify({ stale: { episodes: [], updatedAt: 0 } }), + ); + + vi.resetModules(); + const fresh = await import("./FeedCacheService"); + fresh.getCachedEpisodes(testFeed); + + expect(localStorage.getItem("podnotes:feed-cache:v2")).toBeNull(); + }); + test("clearFeedCache also removes superseded legacy keys", () => { // Covers a clear issued before any cache load, where loadCache's memo would // otherwise short-circuit and leave the v1 blob behind. diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index 87e45f97..272aca71 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -12,15 +12,21 @@ interface CachedFeedData { type FeedCache = Record; -// v2: Episode gained episodeNumber/duration (#34, #88). Bumping the key stops -// reading v1 entries (which lack those fields), so the new template tags populate -// from a fresh parse instead of rendering empty until the TTL expires after an -// upgrade. Superseded keys are actively deleted on load (see LEGACY_STORAGE_KEYS) -// so a stale v1 blob can't linger and eat the localStorage quota. -const STORAGE_KEY = "podnotes:feed-cache:v2"; +// v2: Episode gained episodeNumber/duration (#34, #88). +// v3: retention changed from "first N in feed order" to "newest N by date" +// (#114). An unexpired v2 entry written by the old code holds the first 75 feed +// items, which for an oldest-first feed are the OLDEST episodes; reading it after +// an upgrade would keep Latest Episodes/search stuck on stale items until the TTL +// expired. Bumping the key forces a fresh parse so the new retention applies +// immediately. Superseded keys are actively deleted on load (see +// LEGACY_STORAGE_KEYS) so a stale blob can't linger and eat the localStorage quota. +const STORAGE_KEY = "podnotes:feed-cache:v3"; // Storage keys from earlier cache schemas, removed on first load so they don't -// orphan ~MBs of data (which could push v2 writes over the localStorage quota). -const LEGACY_STORAGE_KEYS = ["podnotes:feed-cache:v1"]; +// orphan ~MBs of data (which could push v3 writes over the localStorage quota). +const LEGACY_STORAGE_KEYS = [ + "podnotes:feed-cache:v1", + "podnotes:feed-cache:v2", +]; const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours. // Keep this >= MAX_EPISODE_LIST_LIMIT (src/constants.ts): the Latest Episodes // list is rebuilt from this persisted cache on a warm start, so a per-feed list