Skip to content
Merged
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
207 changes: 89 additions & 118 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,6 @@ const LATEST_EPISODES_PER_FEED = 10;

type LatestEpisodesByFeed = Map<string, Episode[]>;
type FeedEpisodeSources = Map<string, Episode[]>;
type LatestEpisodePointer = {
feedTitle: string;
index: number;
episode: Episode;
};

function getEpisodeTimestamp(episode?: Episode): number {
if (!episode?.episodeDate) return 0;
Expand All @@ -136,161 +131,137 @@ function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean {
return true;
}

function pushEpisodePointer(
heap: LatestEpisodePointer[],
pointer: LatestEpisodePointer,
): void {
heap.push(pointer);
let idx = heap.length - 1;

while (idx > 0) {
const parent = Math.floor((idx - 1) / 2);
if (
getEpisodeTimestamp(heap[parent].episode) >=
getEpisodeTimestamp(heap[idx].episode)
) {
break;
const latestEpisodeIdentifier = (episode: Episode): string =>
`${episode.podcastName}::${episode.title}`;

function insertEpisodeSorted(
episodes: Episode[],
episodeToInsert: Episode,
limit: number,
): Episode[] {
const nextEpisodes = [...episodes];
const value = getEpisodeTimestamp(episodeToInsert);
let low = 0;
let high = nextEpisodes.length;

while (low < high) {
const mid = (low + high) >> 1;
const midValue = getEpisodeTimestamp(nextEpisodes[mid]);

if (value > midValue) {
high = mid;
} else {
low = mid + 1;
}

heap[idx] = heap[parent];
heap[parent] = pointer;
idx = parent;
}
}

function popEpisodePointer(
heap: LatestEpisodePointer[],
): LatestEpisodePointer | undefined {
if (heap.length === 0) return undefined;

const top = heap[0];
const last = heap.pop();

if (last && heap.length > 0) {
heap[0] = last;
let idx = 0;

while (true) {
const left = idx * 2 + 1;
const right = idx * 2 + 2;
let largest = idx;

if (
left < heap.length &&
getEpisodeTimestamp(heap[left].episode) >
getEpisodeTimestamp(heap[largest].episode)
) {
largest = left;
}
nextEpisodes.splice(low, 0, episodeToInsert);

if (
right < heap.length &&
getEpisodeTimestamp(heap[right].episode) >
getEpisodeTimestamp(heap[largest].episode)
) {
largest = right;
}

if (largest === idx) break;

const temp = heap[idx];
heap[idx] = heap[largest];
heap[largest] = temp;
idx = largest;
}
if (nextEpisodes.length > limit) {
nextEpisodes.length = limit;
}

return top;
return nextEpisodes;
}

// Use a max-heap to merge the latest episodes from each feed without
// resorting the entire cache every time a single feed updates.
function mergeLatestEpisodes(latestByFeed: LatestEpisodesByFeed): Episode[] {
const heap: LatestEpisodePointer[] = [];
function removeFeedEntries(
currentLatest: Episode[],
feedEpisodes: Episode[] | undefined = [],
): Episode[] {
if (!feedEpisodes?.length) {
return currentLatest;
}

for (const [feedTitle, episodes] of latestByFeed.entries()) {
if (!episodes.length) continue;
const feedKeys = new Set(feedEpisodes.map(latestEpisodeIdentifier));

pushEpisodePointer(heap, {
feedTitle,
index: 0,
episode: episodes[0],
});
}
return currentLatest.filter(
(episode) => !feedKeys.has(latestEpisodeIdentifier(episode)),
);
}

const merged: Episode[] = [];
while (heap.length > 0) {
const pointer = popEpisodePointer(heap);
if (!pointer) break;
function updateLatestEpisodesForFeed(
currentLatest: Episode[],
previousFeedEpisodes: Episode[] | undefined,
nextFeedEpisodes: Episode[] | undefined,
limit: number,
): Episode[] {
let nextLatest = removeFeedEntries(currentLatest, previousFeedEpisodes);

merged.push(pointer.episode);
if (!nextFeedEpisodes?.length) {
return nextLatest;
}

const feedEpisodes = latestByFeed.get(pointer.feedTitle);
const nextIndex = pointer.index + 1;
if (feedEpisodes && nextIndex < feedEpisodes.length) {
pushEpisodePointer(heap, {
feedTitle: pointer.feedTitle,
index: nextIndex,
episode: feedEpisodes[nextIndex],
});
}
for (const episode of nextFeedEpisodes) {
nextLatest = insertEpisodeSorted(nextLatest, episode, limit);
}

return merged;
return nextLatest;
}

export const latestEpisodes = readable<Episode[]>([], (set) => {
let latestByFeed: LatestEpisodesByFeed = new Map();
let feedSources: FeedEpisodeSources = new Map();
let mergedLatest: Episode[] = [];

const unsubscribe = episodeCache.subscribe((cache) => {
const cacheEntries = Object.entries(cache);
const feedCount = cacheEntries.length;
const latestLimit = Math.max(
1,
LATEST_EPISODES_PER_FEED * Math.max(feedCount, 1),
);

let changed = false;
let nextMerged = mergedLatest;
const nextSources: FeedEpisodeSources = new Map();
const nextLatestByFeed: LatestEpisodesByFeed = new Map();

for (const [feedTitle, episodes] of Object.entries(cache)) {
for (const [feedTitle, episodes] of cacheEntries) {
nextSources.set(feedTitle, episodes);
const previousSource = feedSources.get(feedTitle);
const previousLatest = latestByFeed.get(feedTitle);

if (previousSource === episodes && previousLatest) {
nextLatestByFeed.set(feedTitle, previousLatest);
continue;
}

const latestForFeed = getLatestEpisodesForFeed(episodes);
nextLatestByFeed.set(feedTitle, latestForFeed);

if (!changed) {
changed =
!previousLatest ||
!shallowEqualEpisodes(previousLatest, latestForFeed);
const previousLatest = latestByFeed.get(feedTitle) || [];

const nextLatestForFeed =
previousSource === episodes && previousLatest
? previousLatest
: getLatestEpisodesForFeed(episodes);

nextLatestByFeed.set(feedTitle, nextLatestForFeed);

if (!shallowEqualEpisodes(previousLatest, nextLatestForFeed)) {
changed = true;
nextMerged = updateLatestEpisodesForFeed(
nextMerged,
previousLatest,
nextLatestForFeed,
latestLimit,
);
}
}

if (!changed) {
for (const feedTitle of feedSources.keys()) {
if (!nextSources.has(feedTitle)) {
changed = true;
break;
}
for (const feedTitle of latestByFeed.keys()) {
if (!nextSources.has(feedTitle)) {
changed = true;
nextMerged = removeFeedEntries(
nextMerged,
latestByFeed.get(feedTitle),
);
}
}

feedSources = nextSources;
latestByFeed = nextLatestByFeed;

if (!changed && nextLatestByFeed.size === latestByFeed.size) {
latestByFeed = nextLatestByFeed;
return;
if (changed) {
mergedLatest = nextMerged;
set(mergedLatest);
}

latestByFeed = nextLatestByFeed;
set(mergeLatestEpisodes(latestByFeed));
});

return () => {
latestByFeed.clear();
feedSources.clear();
mergedLatest = [];
unsubscribe();
};
});
Expand Down