fix(memory): bound visualizer PCM cache and feed episode cache

The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
This commit is contained in:
2026-08-12 21:02:19 -04:00
parent 77531ce41d
commit d7aec4e810
33 changed files with 941 additions and 774 deletions

View File

@@ -49,6 +49,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 5;
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
* The cache exists so fetch-more can page deeper without a refetch; without
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
* RAM for the whole session (the old cache held EVERY parsed episode of
* every feed, contributing hundreds of MB for archive-heavy
* subscriptions). 1000 covers any realistic show's entire history —
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
* by the user's cache preference as usual. */
const MAX_CACHED_EPISODES_PER_FEED = 1000;
/** Yield to the event loop (task queue) so the renderer can paint between
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
* bun:test fake timers trap those (feed-refresh/pagination tests run under
@@ -214,7 +224,6 @@ async function mapWithConcurrency<T, R>(
return results;
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
@@ -262,7 +271,6 @@ function createFeedStore() {
saveFeeds(feeds());
};
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
@@ -320,7 +328,6 @@ function createFeedStore() {
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
@@ -341,7 +348,6 @@ function createFeedStore() {
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
@@ -384,8 +390,14 @@ function createFeedStore() {
if (feedId) {
// Cache the FULL parse — the bound is applied when reading,
// not when writing, so a preference change takes effect
// without a refetch.
fullEpisodeCache.set(feedId, allEpisodes);
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
// so an archive-heavy feed can't pin its entire history in
// RAM for the session (the visible window below is bounded
// by the user's preference regardless).
fullEpisodeCache.set(
feedId,
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
);
}
// Bound the visible window by the user's cache preference.
@@ -410,7 +422,6 @@ function createFeedStore() {
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
@@ -675,7 +686,6 @@ function createFeedStore() {
};
scheduleNextRefresh();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
@@ -706,7 +716,6 @@ function createFeedStore() {
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -717,7 +726,6 @@ function createFeedStore() {
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -728,7 +736,6 @@ function createFeedStore() {
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
@@ -742,7 +749,6 @@ function createFeedStore() {
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
@@ -753,7 +759,6 @@ function createFeedStore() {
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
@@ -766,7 +771,6 @@ function createFeedStore() {
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
@@ -777,7 +781,6 @@ function createFeedStore() {
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
@@ -792,7 +795,6 @@ function createFeedStore() {
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
@@ -853,6 +855,9 @@ function createFeedStore() {
// is its own sync block).
await yieldToUI();
cached = sortEpisodesReverseChronological(cached);
// Same ceiling as fetchEpisodes: the cache (and the paging
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
@@ -900,7 +905,6 @@ function createFeedStore() {
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
@@ -920,7 +924,6 @@ function createFeedStore() {
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
@@ -969,7 +972,6 @@ function createFeedStore() {
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {