Skip to content

Commit 19b24e5

Browse files
committed
fix: performance overhaul to prevent crashes
- Fix blob URL memory leak with BlobUrlManager that tracks and revokes URLs - Fix episode key collision using composite key (podcastName::title) - Fix array bounds check crash in downloadedEpisodes.removeEpisode - Add network request timeouts (30s default) to prevent UI hangs - Fix interval leak in opml.ts TimerNotice - Add localStorage size limits (4MB) to FeedCacheService with LRU eviction - Add null safety checks throughout to prevent Svelte reactivity errors - Add unmount safety flag to prevent state updates after component destroy
1 parent bd62051 commit 19b24e5

File tree

12 files changed

+412
-48
lines changed

12 files changed

+412
-48
lines changed

src/iTunesAPIConsumer.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { requestUrl } from "obsidian";
21
import type { PodcastFeed } from "./types/PodcastFeed";
2+
import { requestWithTimeout, NetworkError } from "./utility/networkRequest";
3+
4+
interface iTunesResult {
5+
collectionName: string;
6+
feedUrl: string;
7+
artworkUrl100: string;
8+
collectionId: string;
9+
}
10+
11+
interface iTunesSearchResponse {
12+
results: iTunesResult[];
13+
}
314

415
export async function queryiTunesPodcasts(query: string): Promise<PodcastFeed[]> {
516
const url = new URL("https://itunes.apple.com/search?");
@@ -8,13 +19,20 @@ export async function queryiTunesPodcasts(query: string): Promise<PodcastFeed[]>
819
url.searchParams.append("limit", "3");
920
url.searchParams.append("kind", "podcast");
1021

11-
const res = await requestUrl({ url: url.href });
12-
const data = res.json.results;
22+
try {
23+
const response = await requestWithTimeout(url.href, { timeoutMs: 15000 });
24+
const data = response.json as iTunesSearchResponse;
1325

14-
return data.map((d: { collectionName: string, feedUrl: string, artworkUrl100: string, collectionId: string }) => ({
15-
title: d.collectionName,
16-
url: d.feedUrl,
17-
artworkUrl: d.artworkUrl100,
18-
collectionId: d.collectionId
19-
}));
26+
return (data.results || []).map((d) => ({
27+
title: d.collectionName,
28+
url: d.feedUrl,
29+
artworkUrl: d.artworkUrl100,
30+
collectionId: d.collectionId,
31+
}));
32+
} catch (error) {
33+
if (error instanceof NetworkError) {
34+
console.error(`iTunes search failed: ${error.message}`);
35+
}
36+
return [];
37+
}
2038
}

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
hidePlayedEpisodes,
1111
volume,
1212
} from "src/store";
13+
import { blobUrlManager } from "src/utility/createMediaUrlObjectFromFilePath";
1314
import { Plugin, type WorkspaceLeaf } from "obsidian";
1415
import { API } from "src/API/API";
1516
import type { IAPI } from "src/API/IAPI";
@@ -374,6 +375,9 @@ export default class PodNotes extends Plugin implements IPodNotes {
374375
this.currentEpisodeController?.off();
375376
this.hidePlayedEpisodesController?.off();
376377
this.volumeUnsubscribe?.();
378+
379+
// Clean up any active blob URLs to prevent memory leaks
380+
blobUrlManager.revokeAll();
377381
}
378382

379383
async loadSettings() {

src/opml.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { get } from "svelte/store";
77
function TimerNotice(heading: string, initialMessage: string) {
88
let currentMessage = initialMessage;
99
const startTime = Date.now();
10-
let stopTime: number;
10+
let stopTime: number | undefined;
11+
let intervalId: ReturnType<typeof setInterval> | undefined;
1112
const notice = new Notice(initialMessage, 0);
1213

1314
function formatMsg(message: string): string {
@@ -19,20 +20,30 @@ function TimerNotice(heading: string, initialMessage: string) {
1920
notice.setMessage(formatMsg(currentMessage));
2021
}
2122

22-
const interval = setInterval(() => {
23-
notice.setMessage(formatMsg(currentMessage));
24-
}, 1000);
25-
2623
function getTime(): string {
2724
return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime);
2825
}
2926

27+
function clearTimer() {
28+
if (intervalId !== undefined) {
29+
clearInterval(intervalId);
30+
intervalId = undefined;
31+
}
32+
}
33+
34+
intervalId = setInterval(() => {
35+
notice.setMessage(formatMsg(currentMessage));
36+
}, 1000);
37+
3038
return {
3139
update,
32-
hide: () => notice.hide(),
40+
hide: () => {
41+
clearTimer();
42+
notice.hide();
43+
},
3344
stop: () => {
3445
stopTime = Date.now();
35-
clearInterval(interval);
46+
clearTimer();
3647
},
3748
};
3849
}

src/parser/feedParser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PodcastFeed } from "src/types/PodcastFeed";
2-
import { requestUrl } from "obsidian";
32
import type { Episode } from "src/types/Episode";
3+
import { requestWithTimeout } from "src/utility/networkRequest";
44

55
export default class FeedParser {
66
private feed: PodcastFeed | undefined;
@@ -125,7 +125,7 @@ export default class FeedParser {
125125
}
126126

127127
private async parseFeed(feedUrl: string): Promise<Document> {
128-
const req = await requestUrl({ url: feedUrl });
128+
const req = await requestWithTimeout(feedUrl, { timeoutMs: 30000 });
129129
const dp = new DOMParser();
130130

131131
const body = dp.parseFromString(req.text, "text/xml");

src/services/FeedCacheService.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type FeedCache = Record<string, CachedFeedData>;
1515
const STORAGE_KEY = "podnotes:feed-cache:v1";
1616
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours.
1717
const MAX_EPISODES_PER_FEED = 75;
18+
const MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB to leave room for other localStorage usage
1819

1920
let cache: FeedCache | null = null;
2021

@@ -55,16 +56,67 @@ function loadCache(): FeedCache {
5556
}
5657
}
5758

59+
function evictOldestEntries(cacheData: FeedCache, targetSizeBytes: number): FeedCache {
60+
const entries = Object.entries(cacheData);
61+
62+
// Sort by updatedAt ascending (oldest first)
63+
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
64+
65+
const result: FeedCache = {};
66+
let currentSize = 0;
67+
68+
// Add entries from newest to oldest until we exceed target size
69+
for (let i = entries.length - 1; i >= 0; i--) {
70+
const [key, value] = entries[i];
71+
const entrySize = JSON.stringify({ [key]: value }).length;
72+
73+
if (currentSize + entrySize <= targetSizeBytes) {
74+
result[key] = value;
75+
currentSize += entrySize;
76+
}
77+
}
78+
79+
return result;
80+
}
81+
5882
function persistCache(): void {
5983
const storage = getStorage();
60-
if (!storage) {
84+
if (!storage || !cache) {
6185
return;
6286
}
6387

6488
try {
65-
storage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {}));
89+
let serialized = JSON.stringify(cache);
90+
91+
// If cache is too large, evict oldest entries
92+
if (serialized.length > MAX_CACHE_SIZE_BYTES) {
93+
console.warn(
94+
`Feed cache size (${serialized.length} bytes) exceeds limit, evicting old entries`,
95+
);
96+
cache = evictOldestEntries(cache, MAX_CACHE_SIZE_BYTES * 0.8); // Target 80% of max
97+
serialized = JSON.stringify(cache);
98+
}
99+
100+
storage.setItem(STORAGE_KEY, serialized);
66101
} catch (error) {
67-
console.error("Failed to persist feed cache:", error);
102+
// Handle quota exceeded error specifically
103+
if (
104+
error instanceof DOMException &&
105+
(error.name === "QuotaExceededError" ||
106+
error.name === "NS_ERROR_DOM_QUOTA_REACHED")
107+
) {
108+
console.warn("localStorage quota exceeded, clearing feed cache");
109+
try {
110+
// Clear cache and try again with empty cache
111+
cache = {};
112+
storage.setItem(STORAGE_KEY, "{}");
113+
} catch {
114+
// If we still can't write, just clear the item
115+
storage.removeItem(STORAGE_KEY);
116+
}
117+
} else {
118+
console.error("Failed to persist feed cache:", error);
119+
}
68120
}
69121
}
70122

src/store/index.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ViewState } from "src/types/ViewState";
88
import type DownloadedEpisode from "src/types/DownloadedEpisode";
99
import { TFile } from "obsidian";
1010
import type { LocalEpisode } from "src/types/LocalEpisode";
11+
import { getEpisodeKey } from "src/utility/episodeKey";
1112

1213
export const plugin = writable<PodNotes>();
1314
export const currentTime = writable<number>(0);
@@ -46,18 +47,51 @@ export const playedEpisodes = (() => {
4647
const store = writable<{ [key: string]: PlayedEpisode }>({});
4748
const { subscribe, update, set } = store;
4849

50+
/**
51+
* Gets played episode data, checking both composite key and legacy title-only key
52+
* for backwards compatibility.
53+
*/
54+
function getPlayedEpisode(
55+
playedEps: { [key: string]: PlayedEpisode },
56+
episode: Episode | null | undefined,
57+
): PlayedEpisode | undefined {
58+
if (!episode) return undefined;
59+
60+
const key = getEpisodeKey(episode);
61+
// First try composite key
62+
if (key && playedEps[key]) {
63+
return playedEps[key];
64+
}
65+
// Fall back to title-only for backwards compatibility
66+
if (episode.title && playedEps[episode.title]) {
67+
return playedEps[episode.title];
68+
}
69+
return undefined;
70+
}
71+
4972
return {
5073
subscribe,
5174
set,
5275
update,
76+
/**
77+
* Gets played episode data with backwards compatibility.
78+
*/
79+
get: (episode: Episode): PlayedEpisode | undefined => {
80+
return getPlayedEpisode(get(store), episode);
81+
},
5382
setEpisodeTime: (
54-
episode: Episode,
83+
episode: Episode | null | undefined,
5584
time: number,
5685
duration: number,
5786
finished: boolean,
5887
) => {
88+
if (!episode) return;
89+
5990
update((playedEpisodes) => {
60-
playedEpisodes[episode.title] = {
91+
const key = getEpisodeKey(episode);
92+
if (!key) return playedEpisodes;
93+
94+
playedEpisodes[key] = {
6195
title: episode.title,
6296
podcastName: episode.podcastName,
6397
time,
@@ -68,29 +102,47 @@ export const playedEpisodes = (() => {
68102
return playedEpisodes;
69103
});
70104
},
71-
markAsPlayed: (episode: Episode) => {
105+
markAsPlayed: (episode: Episode | null | undefined) => {
106+
if (!episode) return;
107+
72108
update((playedEpisodes) => {
73-
const playedEpisode = playedEpisodes[episode.title] || episode;
109+
const key = getEpisodeKey(episode);
110+
if (!key) return playedEpisodes;
74111

75-
if (playedEpisode) {
76-
playedEpisode.time = playedEpisode.duration;
77-
playedEpisode.finished = true;
78-
}
112+
const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
113+
title: episode.title,
114+
podcastName: episode.podcastName,
115+
time: 0,
116+
duration: 0,
117+
finished: false,
118+
};
79119

80-
playedEpisodes[episode.title] = playedEpisode;
120+
playedEpisode.time = playedEpisode.duration;
121+
playedEpisode.finished = true;
122+
123+
playedEpisodes[key] = playedEpisode;
81124
return playedEpisodes;
82125
});
83126
},
84-
markAsUnplayed: (episode: Episode) => {
127+
markAsUnplayed: (episode: Episode | null | undefined) => {
128+
if (!episode) return;
129+
85130
update((playedEpisodes) => {
86-
const playedEpisode = playedEpisodes[episode.title] || episode;
131+
const key = getEpisodeKey(episode);
132+
if (!key) return playedEpisodes;
87133

88-
if (playedEpisode) {
89-
playedEpisode.time = 0;
90-
playedEpisode.finished = false;
91-
}
134+
const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || {
135+
title: episode.title,
136+
podcastName: episode.podcastName,
137+
time: 0,
138+
duration: 0,
139+
finished: false,
140+
};
92141

93-
playedEpisodes[episode.title] = playedEpisode;
142+
playedEpisode.time = 0;
143+
playedEpisode.finished = false;
144+
145+
playedEpisodes[key] = playedEpisode;
94146
return playedEpisodes;
95147
});
96148
},
@@ -313,11 +365,16 @@ export const downloadedEpisodes = (() => {
313365
const index = podcastEpisodes.findIndex(
314366
(e) => e.title === episode.title,
315367
);
316-
const filePath = podcastEpisodes[index].filePath;
317368

369+
// Guard against episode not found
370+
if (index === -1) {
371+
return downloadedEpisodes;
372+
}
373+
374+
const filePath = podcastEpisodes[index].filePath;
318375
podcastEpisodes.splice(index, 1);
319376

320-
if (removeFile) {
377+
if (removeFile && filePath) {
321378
try {
322379
// @ts-ignore: app is not defined in the global scope anymore, but is still
323380
// available. Need to fix this later

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,21 @@
66
import Icon from "../obsidian/Icon.svelte";
77
import Text from "../obsidian/Text.svelte";
88
import Loading from "./Loading.svelte";
9+
import { getEpisodeKey } from "src/utility/episodeKey";
910
1011
export let episodes: Episode[] = [];
1112
export let showThumbnails: boolean = false;
1213
export let showListMenu: boolean = true;
1314
export let isLoading: boolean = false;
1415
let searchInputQuery: string = "";
1516
17+
function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean {
18+
if (!episode) return false;
19+
const key = getEpisodeKey(episode);
20+
// Check composite key first, then fall back to title-only for backwards compat
21+
return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false;
22+
}
23+
1624
const dispatch = createEventDispatcher();
1725
1826
function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) {
@@ -75,7 +83,7 @@
7583
<p>No episodes found.</p>
7684
{/if}
7785
{#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)}
78-
{@const episodePlayed = $playedEpisodes[episode.title]?.finished}
86+
{@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)}
7987
{#if !$hidePlayedEpisodes || !episodePlayed}
8088
<EpisodeListItem
8189
{episode}

0 commit comments

Comments
 (0)