diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md index e55aafd2..2734c3a6 100644 --- a/docs/docs/podcasts.md +++ b/docs/docs/podcasts.md @@ -30,6 +30,20 @@ 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 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/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", diff --git a/src/constants.ts b/src/constants.ts index 74883fce..255dfd02 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,24 @@ 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}. 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 = 75; + type PlaylistSettings = Pick< Playlist, "icon" | "name" | "shouldEpisodeRemoveAfterPlay" | "shouldRepeat" @@ -42,6 +60,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..d7d0f5b1 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); + // 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)), ); @@ -457,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.test.ts b/src/services/FeedCacheService.test.ts index 11e50652..c6ca6401 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) => + 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) => - createEpisode(index + 1), + 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", () => { @@ -85,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 6f840258..272aca71 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -12,16 +12,25 @@ 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 +// 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 @@ -152,6 +161,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, @@ -195,9 +231,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(); diff --git a/src/store/index.test.ts b/src/store/index.test.ts index 90af4177..95c08ebf 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,159 @@ 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(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)", () => { + // `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`, + url: `https://example.com/${podcastName}/${ordinal}`, + description: "", + content: "", + podcastName, + episodeDate: new Date(2020, 0, ordinal), + } satisfies Episode; + }); + return order === "newest-first" ? episodes.reverse() : episodes; + } + + 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(); + }); + + 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)", () => { beforeEach(() => { playedEpisodes.set({}); diff --git a/src/store/index.ts b/src/store/index.ts index ca881ad2..ca592651 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,12 +217,19 @@ 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) - .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 { @@ -283,14 +313,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 +335,7 @@ export const latestEpisodes = readable([], (set) => { const nextLatestForFeed = previousSource === episodes && previousLatest ? previousLatest - : getLatestEpisodesForFeed(episodes); + : getLatestEpisodesForFeed(episodes, perFeedLimit); nextLatestByFeed.set(feedTitle, nextLatestForFeed); @@ -337,13 +367,64 @@ 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(); + const collected: Episode[] = []; + + for (const [feedTitle, episodes] of cacheEntries) { + const nextLatestForFeed = getLatestEpisodesForFeed( + episodes, + perFeedLimit, + ); + nextSources.set(feedTitle, episodes); + nextLatestByFeed.set(feedTitle, nextLatestForFeed); + 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; + + 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..0570e6d4 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,44 @@ 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) => { + // 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(); + }); + // 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") @@ -732,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;