Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/docs/podcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions scripts/provision-obsidian-e2e-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const DEFAULT_PODNOTES_DATA = {
defaultPlaybackRate: 1,
defaultVolume: 1,
hidePlayedEpisodes: false,
episodeListLimit: 10,
playedEpisodes: {},
favorites: {
icon: "lucide-star",
Expand Down
19 changes: 19 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
chhoumann marked this conversation as resolved.

type PlaylistSettings = Pick<
Playlist,
"icon" | "name" | "shouldEpisodeRemoveAfterPlay" | "shouldRepeat"
Expand Down Expand Up @@ -42,6 +60,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
defaultPlaybackRate: 1,
defaultVolume: 1,
hidePlayedEpisodes: false,
episodeListLimit: DEFAULT_EPISODE_LIST_LIMIT,
playedEpisodes: {},
favorites: {
...FAVORITES_SETTINGS,
Expand Down
11 changes: 11 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {
currentEpisode,
downloadedEpisodes,
episodeListLimit,
favorites,
localFiles,
playedEpisodes,
playlists,
queue,
savedFeeds,
hidePlayedEpisodes,
sanitizeEpisodeListLimit,
volume,
} from "src/store";
import { Plugin, type WorkspaceLeaf } from "obsidian";
Expand Down Expand Up @@ -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)),
);
Expand Down Expand Up @@ -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() {
Expand Down
67 changes: 63 additions & 4 deletions src/services/FeedCacheService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 47 additions & 11 deletions src/services/FeedCacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ interface CachedFeedData {

type FeedCache = Record<string, CachedFeedData>;

// 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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
),
Comment thread
chhoumann marked this conversation as resolved.
};

persistCache();
Expand Down
Loading
Loading