Skip to content

Commit 7d14017

Browse files
authored
Optimize latest episodes aggregation (#142)
1 parent d79ead6 commit 7d14017

2 files changed

Lines changed: 242 additions & 50 deletions

File tree

src/store/index.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { get, writable } from "svelte/store";
1+
import { get, readable, writable } from "svelte/store";
22
import type PodNotes from "src/main";
33
import type { Episode } from "src/types/Episode";
44
import type { PlayedEpisode } from "src/types/PlayedEpisode";
@@ -102,6 +102,199 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({});
102102

103103
export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({});
104104

105+
const LATEST_EPISODES_PER_FEED = 10;
106+
107+
type LatestEpisodesByFeed = Map<string, Episode[]>;
108+
type FeedEpisodeSources = Map<string, Episode[]>;
109+
type LatestEpisodePointer = {
110+
feedTitle: string;
111+
index: number;
112+
episode: Episode;
113+
};
114+
115+
function getEpisodeTimestamp(episode?: Episode): number {
116+
if (!episode?.episodeDate) return 0;
117+
118+
return Number(episode.episodeDate);
119+
}
120+
121+
function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] {
122+
if (!episodes?.length) return [];
123+
124+
return episodes
125+
.slice(0, LATEST_EPISODES_PER_FEED)
126+
.sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a));
127+
}
128+
129+
function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean {
130+
if (!a || !b || a.length !== b.length) return false;
131+
132+
for (let i = 0; i < a.length; i += 1) {
133+
if (a[i] !== b[i]) return false;
134+
}
135+
136+
return true;
137+
}
138+
139+
function pushEpisodePointer(
140+
heap: LatestEpisodePointer[],
141+
pointer: LatestEpisodePointer,
142+
): void {
143+
heap.push(pointer);
144+
let idx = heap.length - 1;
145+
146+
while (idx > 0) {
147+
const parent = Math.floor((idx - 1) / 2);
148+
if (
149+
getEpisodeTimestamp(heap[parent].episode) >=
150+
getEpisodeTimestamp(heap[idx].episode)
151+
) {
152+
break;
153+
}
154+
155+
heap[idx] = heap[parent];
156+
heap[parent] = pointer;
157+
idx = parent;
158+
}
159+
}
160+
161+
function popEpisodePointer(
162+
heap: LatestEpisodePointer[],
163+
): LatestEpisodePointer | undefined {
164+
if (heap.length === 0) return undefined;
165+
166+
const top = heap[0];
167+
const last = heap.pop();
168+
169+
if (last && heap.length > 0) {
170+
heap[0] = last;
171+
let idx = 0;
172+
173+
while (true) {
174+
const left = idx * 2 + 1;
175+
const right = idx * 2 + 2;
176+
let largest = idx;
177+
178+
if (
179+
left < heap.length &&
180+
getEpisodeTimestamp(heap[left].episode) >
181+
getEpisodeTimestamp(heap[largest].episode)
182+
) {
183+
largest = left;
184+
}
185+
186+
if (
187+
right < heap.length &&
188+
getEpisodeTimestamp(heap[right].episode) >
189+
getEpisodeTimestamp(heap[largest].episode)
190+
) {
191+
largest = right;
192+
}
193+
194+
if (largest === idx) break;
195+
196+
const temp = heap[idx];
197+
heap[idx] = heap[largest];
198+
heap[largest] = temp;
199+
idx = largest;
200+
}
201+
}
202+
203+
return top;
204+
}
205+
206+
// Use a max-heap to merge the latest episodes from each feed without
207+
// resorting the entire cache every time a single feed updates.
208+
function mergeLatestEpisodes(latestByFeed: LatestEpisodesByFeed): Episode[] {
209+
const heap: LatestEpisodePointer[] = [];
210+
211+
for (const [feedTitle, episodes] of latestByFeed.entries()) {
212+
if (!episodes.length) continue;
213+
214+
pushEpisodePointer(heap, {
215+
feedTitle,
216+
index: 0,
217+
episode: episodes[0],
218+
});
219+
}
220+
221+
const merged: Episode[] = [];
222+
while (heap.length > 0) {
223+
const pointer = popEpisodePointer(heap);
224+
if (!pointer) break;
225+
226+
merged.push(pointer.episode);
227+
228+
const feedEpisodes = latestByFeed.get(pointer.feedTitle);
229+
const nextIndex = pointer.index + 1;
230+
if (feedEpisodes && nextIndex < feedEpisodes.length) {
231+
pushEpisodePointer(heap, {
232+
feedTitle: pointer.feedTitle,
233+
index: nextIndex,
234+
episode: feedEpisodes[nextIndex],
235+
});
236+
}
237+
}
238+
239+
return merged;
240+
}
241+
242+
export const latestEpisodes = readable<Episode[]>([], (set) => {
243+
let latestByFeed: LatestEpisodesByFeed = new Map();
244+
let feedSources: FeedEpisodeSources = new Map();
245+
246+
const unsubscribe = episodeCache.subscribe((cache) => {
247+
let changed = false;
248+
const nextSources: FeedEpisodeSources = new Map();
249+
const nextLatestByFeed: LatestEpisodesByFeed = new Map();
250+
251+
for (const [feedTitle, episodes] of Object.entries(cache)) {
252+
nextSources.set(feedTitle, episodes);
253+
const previousSource = feedSources.get(feedTitle);
254+
const previousLatest = latestByFeed.get(feedTitle);
255+
256+
if (previousSource === episodes && previousLatest) {
257+
nextLatestByFeed.set(feedTitle, previousLatest);
258+
continue;
259+
}
260+
261+
const latestForFeed = getLatestEpisodesForFeed(episodes);
262+
nextLatestByFeed.set(feedTitle, latestForFeed);
263+
264+
if (!changed) {
265+
changed =
266+
!previousLatest ||
267+
!shallowEqualEpisodes(previousLatest, latestForFeed);
268+
}
269+
}
270+
271+
if (!changed) {
272+
for (const feedTitle of feedSources.keys()) {
273+
if (!nextSources.has(feedTitle)) {
274+
changed = true;
275+
break;
276+
}
277+
}
278+
}
279+
280+
feedSources = nextSources;
281+
282+
if (!changed && nextLatestByFeed.size === latestByFeed.size) {
283+
latestByFeed = nextLatestByFeed;
284+
return;
285+
}
286+
287+
latestByFeed = nextLatestByFeed;
288+
set(mergeLatestEpisodes(latestByFeed));
289+
});
290+
291+
return () => {
292+
latestByFeed.clear();
293+
feedSources.clear();
294+
unsubscribe();
295+
};
296+
});
297+
105298
export const downloadedEpisodes = (() => {
106299
const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({});
107300
const { subscribe, update, set } = store;

src/ui/PodcastView/PodcastView.svelte

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,38 @@
11
<script lang="ts">
22
import type { PodcastFeed } from "src/types/PodcastFeed";
33
import PodcastGrid from "./PodcastGrid.svelte";
4-
import {
5-
currentEpisode,
6-
savedFeeds,
7-
episodeCache,
8-
playlists,
9-
queue,
10-
favorites,
11-
localFiles,
12-
podcastView,
13-
viewState,
14-
downloadedEpisodes,
15-
plugin,
16-
} from "src/store";
4+
import {
5+
currentEpisode,
6+
savedFeeds,
7+
episodeCache,
8+
latestEpisodes as latestEpisodesStore,
9+
playlists,
10+
queue,
11+
favorites,
12+
localFiles,
13+
podcastView,
14+
viewState,
15+
downloadedEpisodes,
16+
plugin,
17+
} from "src/store";
1718
import EpisodePlayer from "./EpisodePlayer.svelte";
1819
import EpisodeList from "./EpisodeList.svelte";
1920
import type { Episode } from "src/types/Episode";
2021
import FeedParser from "src/parser/feedParser";
2122
import TopBar from "./TopBar.svelte";
2223
import { ViewState } from "src/types/ViewState";
23-
import { onMount } from "svelte";
24+
import { onMount } from "svelte";
2425
import EpisodeListHeader from "./EpisodeListHeader.svelte";
2526
import Icon from "../obsidian/Icon.svelte";
2627
import { debounce } from "obsidian";
2728
import searchEpisodes from "src/utility/searchEpisodes";
2829
import type { Playlist } from "src/types/Playlist";
2930
import spawnEpisodeContextMenu from "./spawnEpisodeContextMenu";
30-
import {
31-
getCachedEpisodes,
32-
setCachedEpisodes,
33-
} from "src/services/FeedCacheService";
34-
import { get } from "svelte/store";
31+
import {
32+
getCachedEpisodes,
33+
setCachedEpisodes,
34+
} from "src/services/FeedCacheService";
35+
import { get } from "svelte/store";
3536
3637
let feeds: PodcastFeed[] = [];
3738
let selectedFeed: PodcastFeed | null = null;
@@ -40,41 +41,39 @@ import { get } from "svelte/store";
4041
let displayedPlaylists: Playlist[] = [];
4142
let latestEpisodes: Episode[] = [];
4243
43-
onMount(() => {
44-
const unsubscribePlaylists = playlists.subscribe((pl) => {
45-
displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)];
46-
});
44+
onMount(() => {
45+
const unsubscribePlaylists = playlists.subscribe((pl) => {
46+
displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)];
47+
});
4748
48-
const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => {
49-
feeds = Object.values(storeValue);
50-
});
49+
const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => {
50+
feeds = Object.values(storeValue);
51+
});
5152
52-
const unsubscribeEpisodeCache = episodeCache.subscribe((cache) => {
53-
latestEpisodes = Object.entries(cache)
54-
.map(([_, episodes]) => episodes.slice(0, 10))
55-
.flat()
56-
.sort((a, b) => {
57-
if (a.episodeDate && b.episodeDate)
58-
return Number(b.episodeDate) - Number(a.episodeDate);
53+
const unsubscribeLatestEpisodes = latestEpisodesStore.subscribe(
54+
(episodes) => {
55+
latestEpisodes = episodes;
5956
60-
return 0;
61-
});
62-
});
57+
if (!selectedFeed && !selectedPlaylist) {
58+
displayedEpisodes = episodes;
59+
}
60+
},
61+
);
6362
64-
(async () => {
65-
await fetchEpisodesInAllFeeds(feeds);
63+
(async () => {
64+
await fetchEpisodesInAllFeeds(feeds);
6665
67-
if (!selectedFeed) {
68-
displayedEpisodes = latestEpisodes;
69-
}
70-
})();
71-
72-
return () => {
73-
unsubscribeEpisodeCache();
74-
unsubscribeSavedFeeds();
75-
unsubscribePlaylists();
76-
};
77-
});
66+
if (!selectedFeed) {
67+
displayedEpisodes = latestEpisodes;
68+
}
69+
})();
70+
71+
return () => {
72+
unsubscribeLatestEpisodes();
73+
unsubscribeSavedFeeds();
74+
unsubscribePlaylists();
75+
};
76+
});
7877
7978
async function fetchEpisodes(
8079
feed: PodcastFeed,

0 commit comments

Comments
 (0)