From 55b0985f02ff0f7f1b044185c8ff14a18f774981 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 3 May 2026 11:36:27 +0200 Subject: [PATCH 1/2] feat(podcast-view): add played episodes view --- docs/docs/podcasts.md | 2 + src/constants.ts | 7 + src/store/index.ts | 39 ++- src/types/Playlist.ts | 1 + src/ui/PodcastView/EpisodeList.svelte | 72 +++--- src/ui/PodcastView/EpisodeListItem.svelte | 14 ++ .../PodcastView.integration.test.ts | 37 +++ src/ui/PodcastView/PodcastView.svelte | 223 +++++++++++++++--- src/ui/PodcastView/spawnEpisodeContextMenu.ts | 17 +- src/utility/episodeListEntry.test.ts | 106 +++++++++ src/utility/episodeListEntry.ts | 146 ++++++++++++ src/utility/episodeStatus.test.ts | 74 ++++++ src/utility/episodeStatus.ts | 65 +++++ 13 files changed, 720 insertions(+), 83 deletions(-) create mode 100644 src/utility/episodeListEntry.test.ts create mode 100644 src/utility/episodeListEntry.ts create mode 100644 src/utility/episodeStatus.test.ts create mode 100644 src/utility/episodeStatus.ts diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md index 5f838ee..09f6e1e 100644 --- a/docs/docs/podcasts.md +++ b/docs/docs/podcasts.md @@ -14,6 +14,8 @@ The playlists are shown in the episode grid, represented by their icons. By default, you will have a queue playlist and a favorites playlist. +PodNotes also shows a Played playlist in the episode grid. It is a virtual playlist that lists episodes marked as played across all podcasts. Episodes still available in your feeds can be played or managed like normal episodes. Older played-history entries that can no longer be found in current feeds remain visible so you can mark them as unplayed. + You can delete playlists by pressing the trash bin icon next to the playlist name. The icon will change to a checkmark, which should be pressed within a short duration to confirm the deletion. diff --git a/src/constants.ts b/src/constants.ts index 315a8c3..09838bc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,6 +29,13 @@ export const LOCAL_FILES_SETTINGS: PlaylistSettings = { shouldRepeat: false, }; +export const PLAYED_SETTINGS: PlaylistSettings = { + icon: "check-square", + name: "Played", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, +}; + export const DEFAULT_SETTINGS: IPodNotesSettings = { savedFeeds: {}, podNotes: {}, diff --git a/src/store/index.ts b/src/store/index.ts index 5ec5f37..e20ae34 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,6 +9,7 @@ import type DownloadedEpisode from "src/types/DownloadedEpisode"; import { TFile } from "obsidian"; import type { LocalEpisode } from "src/types/LocalEpisode"; import { getEpisodeKey } from "src/utility/episodeKey"; +import { getPlayedEpisode } from "src/utility/episodeStatus"; export const plugin = writable(); export const currentTime = writable(0); @@ -51,28 +52,6 @@ export const playedEpisodes = (() => { const store = writable<{ [key: string]: PlayedEpisode }>({}); const { subscribe, update, set } = store; - /** - * Gets played episode data, checking both composite key and legacy title-only key - * for backwards compatibility. - */ - function getPlayedEpisode( - playedEps: { [key: string]: PlayedEpisode }, - episode: Episode | null | undefined, - ): PlayedEpisode | undefined { - if (!episode) return undefined; - - const key = getEpisodeKey(episode); - // First try composite key - if (key && playedEps[key]) { - return playedEps[key]; - } - // Fall back to title-only for backwards compatibility - if (episode.title && playedEps[episode.title]) { - return playedEps[episode.title]; - } - return undefined; - } - return { subscribe, set, @@ -150,6 +129,22 @@ export const playedEpisodes = (() => { return playedEpisodes; }); }, + markKeyAsUnplayed: (key: string) => { + if (!key) return; + + update((playedEpisodes) => { + const playedEpisode = playedEpisodes[key]; + if (!playedEpisode) return playedEpisodes; + + playedEpisodes[key] = { + ...playedEpisode, + time: 0, + finished: false, + }; + + return playedEpisodes; + }); + }, }; })(); diff --git a/src/types/Playlist.ts b/src/types/Playlist.ts index 64f6a6d..f84d81d 100644 --- a/src/types/Playlist.ts +++ b/src/types/Playlist.ts @@ -10,4 +10,5 @@ export type Playlist = { shouldEpisodeRemoveAfterPlay: boolean; shouldRepeat: boolean; + isVirtual?: boolean; } diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index 5db185c..c650c9b 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -7,31 +7,47 @@ import Text from "../obsidian/Text.svelte"; import Loading from "./Loading.svelte"; import { getEpisodeKey } from "src/utility/episodeKey"; + import { isEpisodeFinished } from "src/utility/episodeStatus"; + import { + createEpisodeListEntries, + type EpisodeListEntry, + } from "src/utility/episodeListEntry"; export let episodes: Episode[] = []; + export let episodeEntries: EpisodeListEntry[] | null = null; export let showThumbnails: boolean = false; export let showListMenu: boolean = true; + export let showPlayedToggle: boolean = true; + export let alwaysShowPlayedEpisodes: boolean = false; export let isLoading: boolean = false; let searchInputQuery: string = ""; - - function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean { - if (!episode) return false; - const key = getEpisodeKey(episode); - // Check composite key first, then fall back to title-only for backwards compat - return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false; - } + $: listEntries = episodeEntries ?? createEpisodeListEntries(episodes); + $: shouldHidePlayedEpisodes = $hidePlayedEpisodes && !alwaysShowPlayedEpisodes; + $: visibleEntries = listEntries.filter( + (entry) => + !shouldHidePlayedEpisodes || + !isEpisodeFinished(entry.episode, $playedEpisodes), + ); const dispatch = createEventDispatcher(); - function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) { - dispatch("clickEpisode", { episode: event.detail.episode }); + function forwardClickEpisode( + entry: EpisodeListEntry, + event: CustomEvent<{ episode: Episode }>, + ) { + dispatch("clickEpisode", { + episode: event.detail.episode, + entry, + }); } function forwardContextMenuEpisode( + entry: EpisodeListEntry, event: CustomEvent<{ episode: Episode; event: MouseEvent }> ) { dispatch("contextMenuEpisode", { episode: event.detail.episode, + entry, event: event.detail.event, }); } @@ -56,13 +72,15 @@ }} /> - hidePlayedEpisodes.update((value) => !value)} - /> + {#if showPlayedToggle} + hidePlayedEpisodes.update((value) => !value)} + /> + {/if} Fetching episodes... {/if} - {#if episodes.length === 0 && !isLoading} + {#if visibleEntries.length === 0 && !isLoading}

No episodes found.

{/if} - {#each episodes as episode, index (getEpisodeKey(episode) ?? `${episode.title}-${episode.episodeDate ?? ""}-${index}`)} - {@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)} - {#if !$hidePlayedEpisodes || !episodePlayed} - - {/if} + {#each visibleEntries as entry, index (getEpisodeKey(entry.episode) ?? `${entry.episode.title}-${entry.episode.episodeDate ?? ""}-${index}`)} + {/each} diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index e6a5a04..3aa986d 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -6,6 +6,7 @@ export let episode: Episode; export let episodeFinished: boolean = false; export let showEpisodeImage: boolean = false; + export let unavailableReason: string | undefined = undefined; const dispatch = createEventDispatcher(); const dateFormatter = new Intl.DateTimeFormat("en-GB", { @@ -57,6 +58,7 @@ class="podcast-episode-item" on:click={onClickEpisode} on:contextmenu={onContextMenu} + title={unavailableReason ?? episode.title} > {#if showEpisodeImage && episode?.artworkUrl}
@@ -75,6 +77,9 @@
{date} {episode.title} + {#if unavailableReason} + {unavailableReason} + {/if}
@@ -119,6 +124,10 @@ opacity: 0.6; } + .podcast-episode-item:has(.episode-item-status) { + opacity: 0.75; + } + .podcast-episode-information { display: flex; flex-direction: column; @@ -174,4 +183,9 @@ object-fit: cover; border-radius: 0.375rem; } + + .episode-item-status { + font-size: 0.75rem; + color: var(--text-muted); + } diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 0341fc0..893b621 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -13,6 +13,8 @@ import createPodcastNote from "src/createPodcastNote"; import { currentEpisode, episodeCache, + hidePlayedEpisodes, + playedEpisodes, plugin, savedFeeds, viewState, @@ -60,6 +62,8 @@ const testEpisode: Episode = { function resetStores() { savedFeeds.set({}); episodeCache.set({}); + hidePlayedEpisodes.set(false); + playedEpisodes.set({}); viewState.set(ViewState.PodcastGrid); currentEpisode.update(() => undefined as unknown as Episode); plugin.set(undefined as never); @@ -229,4 +233,37 @@ describe("PodcastView integration flow", () => { ).not.toBeInTheDocument(), ); }); + + test("opens a global played episodes view from the podcast grid", async () => { + const playedEpisode: Episode = { + title: "Already Finished", + streamUrl: "https://pod.example.com/finished.mp3", + url: "https://pod.example.com/finished", + description: "Finished episode description", + content: "

Finished episode content

", + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date("2023-01-15T00:00:00.000Z"), + }; + + mockGetEpisodes.mockResolvedValue([testEpisode, playedEpisode]); + hidePlayedEpisodes.set(true); + playedEpisodes.set({ + [`${testFeed.title}::${playedEpisode.title}`]: { + title: playedEpisode.title, + podcastName: testFeed.title, + time: 100, + duration: 100, + finished: true, + }, + }); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await fireEvent.click(playedCard); + + expect(await screen.findByText("Already Finished")).toBeInTheDocument(); + expect(screen.getByText("Played")).toBeInTheDocument(); + }); }); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index fe71325..7dbadf3 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -14,6 +14,7 @@ viewState, downloadedEpisodes, plugin, + playedEpisodes, } from "src/store"; import EpisodePlayer from "./EpisodePlayer.svelte"; import EpisodeList from "./EpisodeList.svelte"; @@ -24,7 +25,7 @@ import { onMount, onDestroy } from "svelte"; import EpisodeListHeader from "./EpisodeListHeader.svelte"; import Icon from "../obsidian/Icon.svelte"; - import { debounce } from "obsidian"; + import { debounce, Notice } from "obsidian"; import searchEpisodes from "src/utility/searchEpisodes"; import type { Playlist } from "src/types/Playlist"; import spawnEpisodeContextMenu from "./spawnEpisodeContextMenu"; @@ -33,11 +34,21 @@ setCachedEpisodes, } from "src/services/FeedCacheService"; import { get } from "svelte/store"; + import { PLAYED_SETTINGS } from "src/constants"; + import { getFinishedPlayedEpisodeRecords } from "src/utility/episodeStatus"; + import { + buildPlayedEpisodeListEntries, + createPlayedEpisodePlaceholder, + type EpisodeListEntry, + type PlayedEpisodeListEntry, + } from "src/utility/episodeListEntry"; let feeds: PodcastFeed[] = []; let selectedFeed: PodcastFeed | null = null; let selectedPlaylist: Playlist | null = null; + let isShowingPlayedEpisodes: boolean = false; let displayedEpisodes: Episode[] = []; + let displayedEpisodeEntries: EpisodeListEntry[] | null = null; let displayedPlaylists: Playlist[] = []; let latestEpisodes: Episode[] = []; let isFetchingEpisodes: boolean = false; @@ -59,9 +70,29 @@ $: isFetchingEpisodes = loadingFeedNames.length > 0; onMount(() => { - const unsubscribePlaylists = playlists.subscribe((pl) => { - displayedPlaylists = [get(queue), get(favorites), get(localFiles), ...Object.values(pl)]; - }); + const updateDisplayedPlaylists = () => { + const customPlaylists = Object.values(get(playlists)); + displayedPlaylists = [ + get(queue), + get(favorites), + get(localFiles), + getPlayedPlaylist(), + ...customPlaylists, + ]; + }; + + const playlistUnsubscribers = [ + playlists.subscribe(updateDisplayedPlaylists), + queue.subscribe(updateDisplayedPlaylists), + favorites.subscribe(updateDisplayedPlaylists), + localFiles.subscribe(updateDisplayedPlaylists), + playedEpisodes.subscribe(() => { + updateDisplayedPlaylists(); + if (isShowingPlayedEpisodes) { + updateDisplayedPlayedEpisodes(); + } + }), + ]; const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => { const updatedFeeds = Object.values(storeValue); @@ -90,7 +121,8 @@ if ( currentViewState === ViewState.EpisodeList && !selectedFeed && - !selectedPlaylist + !selectedPlaylist && + !isShowingPlayedEpisodes ) { displayedEpisodes = currentSearchQuery ? searchEpisodes(currentSearchQuery, episodes) @@ -103,7 +135,7 @@ unsubscribeLatestEpisodes(); unsubscribeViewState(); unsubscribeSavedFeeds(); - unsubscribePlaylists(); + playlistUnsubscribers.forEach((unsubscribe) => unsubscribe()); }; }); @@ -162,6 +194,82 @@ } } + function getPlayedPlaylist(): Playlist { + return { + ...PLAYED_SETTINGS, + episodes: getFinishedPlayedEpisodeRecords(get(playedEpisodes)).map( + ({ episode }) => createPlayedEpisodePlaceholder(episode), + ), + isVirtual: true, + }; + } + + function getEpisodeSources(): Episode[][] { + const cachedEpisodes = Object.values(get(episodeCache)); + const downloaded = Object.values(get(downloadedEpisodes)); + const userPlaylists = Object.values(get(playlists)).map( + (playlist) => playlist.episodes, + ); + + return [ + ...cachedEpisodes, + ...downloaded, + get(queue).episodes, + get(favorites).episodes, + get(localFiles).episodes, + ...userPlaylists, + ]; + } + + function filterPlayedEntries( + entries: PlayedEpisodeListEntry[], + query: string, + ): PlayedEpisodeListEntry[] { + if (!query) return entries; + + const entriesByEpisode = new Map( + entries.map((entry) => [entry.episode, entry]), + ); + + return searchEpisodes( + query, + entries.map((entry) => entry.episode), + ) + .map((episode) => entriesByEpisode.get(episode)) + .filter((entry): entry is PlayedEpisodeListEntry => Boolean(entry)); + } + + function updateDisplayedPlayedEpisodes() { + const entries = buildPlayedEpisodeListEntries( + get(playedEpisodes), + getEpisodeSources(), + ); + displayedEpisodeEntries = filterPlayedEntries( + entries, + currentSearchQuery, + ); + displayedEpisodes = displayedEpisodeEntries.map((entry) => entry.episode); + } + + function showLatestEpisodes() { + selectedFeed = null; + selectedPlaylist = null; + isShowingPlayedEpisodes = false; + displayedEpisodeEntries = null; + displayedEpisodes = currentSearchQuery + ? searchEpisodes(currentSearchQuery, latestEpisodes) + : latestEpisodes; + viewState.set(ViewState.EpisodeList); + } + + function getPlayedEpisodeKey(entry: EpisodeListEntry): string | undefined { + if (!("playedEpisodeKey" in entry)) return undefined; + + return typeof entry.playedEpisodeKey === "string" + ? entry.playedEpisodeKey + : undefined; + } + function setFeedLoading(feedTitle: string, isLoading: boolean) { // Don't update state if component is unmounted if (!isMounted) return; @@ -178,7 +286,8 @@ } function fetchEpisodesInAllFeeds( - feedsToSearch: PodcastFeed[] + feedsToSearch: PodcastFeed[], + useCache: boolean = true, ): Promise { if (!feedsToSearch.length) return Promise.resolve(); @@ -187,7 +296,7 @@ setFeedLoading(feed.title, true); try { - await fetchEpisodes(feed); + await fetchEpisodes(feed, useCache); } finally { setFeedLoading(feed.title, false); } @@ -201,6 +310,9 @@ const { feed } = event.detail; selectedFeed = feed; + selectedPlaylist = null; + isShowingPlayedEpisodes = false; + displayedEpisodeEntries = null; displayedEpisodes = []; viewState.set(ViewState.EpisodeList); setFeedLoading(feed.title, true); @@ -215,26 +327,54 @@ } } - function handleClickEpisode(event: CustomEvent<{ episode: Episode }>) { - const { episode } = event.detail; + function handleClickEpisode( + event: CustomEvent<{ episode: Episode; entry: EpisodeListEntry }>, + ) { + const { episode, entry } = event.detail; + if (!entry.isAvailable) { + new Notice("This played episode is no longer available in current feeds."); + return; + } + currentEpisode.set(episode); viewState.set(ViewState.Player); } function handleContextMenuEpisode({ - detail: { event, episode }, - }: CustomEvent<{ episode: Episode; event: MouseEvent }>) { - spawnEpisodeContextMenu(episode, event); + detail: { event, episode, entry }, + }: CustomEvent<{ episode: Episode; entry: EpisodeListEntry; event: MouseEvent }>) { + spawnEpisodeContextMenu( + episode, + event, + entry.isAvailable + ? undefined + : { + play: true, + download: true, + createNote: true, + favorite: true, + queue: true, + playlists: true, + }, + getPlayedEpisodeKey(entry), + ); } async function handleClickRefresh() { + if (isShowingPlayedEpisodes) { + await fetchEpisodesInAllFeeds(feeds, false); + updateDisplayedPlayedEpisodes(); + return; + } + if (!selectedFeed) return; setFeedLoading(selectedFeed.title, true); try { const episodes = await fetchEpisodes(selectedFeed, false); + displayedEpisodeEntries = null; displayedEpisodes = currentSearchQuery ? searchEpisodes(currentSearchQuery, episodes) : episodes; @@ -247,22 +387,53 @@ const { query } = event.detail; currentSearchQuery = query; + if (isShowingPlayedEpisodes) { + updateDisplayedPlayedEpisodes(); + return; + } + if (selectedFeed) { const cache = get(episodeCache); const episodesInFeed = cache[selectedFeed.title] ?? []; + displayedEpisodeEntries = null; displayedEpisodes = searchEpisodes(query, episodesInFeed); return; } + if (selectedPlaylist) { + displayedEpisodeEntries = null; + displayedEpisodes = searchEpisodes(query, selectedPlaylist.episodes); + return; + } + + displayedEpisodeEntries = null; displayedEpisodes = searchEpisodes(query, latestEpisodes); }, 250); function handleClickPlaylist( event: CustomEvent<{ event: MouseEvent; playlist: Playlist }> ) { - const { event: clickEvent, playlist } = event.detail; + const { playlist } = event.detail; + + if (playlist.isVirtual && playlist.name === PLAYED_SETTINGS.name) { + selectedFeed = null; + selectedPlaylist = playlist; + isShowingPlayedEpisodes = true; + displayedEpisodes = []; + displayedEpisodeEntries = []; + viewState.set(ViewState.EpisodeList); + + void fetchEpisodesInAllFeeds(feeds).then(() => { + updateDisplayedPlayedEpisodes(); + }); + return; + } if (playlist.name === $queue.name && $queue.episodes.length > 0) { + selectedFeed = null; + selectedPlaylist = null; + isShowingPlayedEpisodes = false; + displayedEpisodeEntries = null; // Only need to set the current episode if there isn't any. // The current episode _is_ the front of the queue. if (!$currentEpisode) { @@ -271,7 +442,10 @@ viewState.set(ViewState.Player); } else { + selectedFeed = null; selectedPlaylist = playlist; + isShowingPlayedEpisodes = false; + displayedEpisodeEntries = null; displayedEpisodes = playlist.episodes; viewState.set(ViewState.EpisodeList); @@ -306,7 +480,10 @@ {/if} { - selectedFeed = null; - displayedEpisodes = currentSearchQuery - ? searchEpisodes(currentSearchQuery, latestEpisodes) - : latestEpisodes; - viewState.set(ViewState.EpisodeList); - }} + on:click={showLatestEpisodes} > { - selectedPlaylist = null; - displayedEpisodes = currentSearchQuery - ? searchEpisodes(currentSearchQuery, latestEpisodes) - : latestEpisodes; - viewState.set(ViewState.EpisodeList); - }} + on:click={showLatestEpisodes} > + disabledMenuItems?: Partial, + playedEpisodeKey?: string, ) { const menu = new Menu(); @@ -34,13 +36,20 @@ export default function spawnEpisodeContextMenu( } if (!disabledMenuItems?.markPlayed) { - const episodeIsPlayed = Object.values(get(playedEpisodes)).find(e => (e.title === episode.title && e.finished)); + const playedEpisodeMap = get(playedEpisodes); + const episodeIsPlayed = playedEpisodeKey + ? playedEpisodeMap[playedEpisodeKey]?.finished ?? isEpisodeFinished(episode, playedEpisodeMap) + : isEpisodeFinished(episode, playedEpisodeMap); menu.addItem(item => item - .setIcon(episodeIsPlayed ? "cross" : "check") + .setIcon(episodeIsPlayed ? "x" : "check") .setTitle(`Mark as ${episodeIsPlayed ? "Unplayed" : "Played"}`) .onClick(() => { if (episodeIsPlayed) { - playedEpisodes.markAsUnplayed(episode); + if (playedEpisodeKey) { + playedEpisodes.markKeyAsUnplayed(playedEpisodeKey); + } else { + playedEpisodes.markAsUnplayed(episode); + } } else { playedEpisodes.markAsPlayed(episode); } diff --git a/src/utility/episodeListEntry.test.ts b/src/utility/episodeListEntry.test.ts new file mode 100644 index 0000000..27a7a7f --- /dev/null +++ b/src/utility/episodeListEntry.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import type { Episode } from "src/types/Episode"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { buildPlayedEpisodeListEntries } from "./episodeListEntry"; + +function episode( + title: string, + podcastName: string, + episodeDate: string, +): Episode { + return { + title, + podcastName, + streamUrl: `https://example.com/${title}.mp3`, + url: `https://example.com/${title}`, + description: "", + content: "", + episodeDate: new Date(episodeDate), + }; +} + +function played( + title: string, + podcastName: string, + finished: boolean = true, +): PlayedEpisode { + return { + title, + podcastName, + time: finished ? 100 : 25, + duration: 100, + finished, + }; +} + +describe("episodeListEntry", () => { + test("resolves finished played records against available episode sources", () => { + const resolvedEpisode = episode( + "Resolved", + "Design Podcast", + "2024-02-01T00:00:00.000Z", + ); + + const entries = buildPlayedEpisodeListEntries( + { + "Design Podcast::Resolved": played("Resolved", "Design Podcast"), + }, + [[resolvedEpisode]], + ); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + episode: resolvedEpisode, + isAvailable: true, + }); + }); + + test("keeps unavailable played records visible", () => { + const entries = buildPlayedEpisodeListEntries( + { + "Design Podcast::Missing": played("Missing", "Design Podcast"), + }, + [[]], + ); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + isAvailable: false, + unavailableReason: "Unavailable in current feeds", + episode: { + title: "Missing", + podcastName: "Design Podcast", + streamUrl: "", + }, + }); + }); + + test("sorts available played episodes by publish date and unavailable records last", () => { + const olderEpisode = episode( + "Older", + "Design Podcast", + "2024-01-01T00:00:00.000Z", + ); + const newerEpisode = episode( + "Newer", + "Design Podcast", + "2024-03-01T00:00:00.000Z", + ); + + const entries = buildPlayedEpisodeListEntries( + { + "Design Podcast::Older": played("Older", "Design Podcast"), + "Design Podcast::Missing": played("Missing", "Design Podcast"), + "Design Podcast::Newer": played("Newer", "Design Podcast"), + }, + [[olderEpisode, newerEpisode]], + ); + + expect(entries.map((entry) => entry.episode.title)).toEqual([ + "Newer", + "Older", + "Missing", + ]); + }); +}); diff --git a/src/utility/episodeListEntry.ts b/src/utility/episodeListEntry.ts new file mode 100644 index 0000000..006609a --- /dev/null +++ b/src/utility/episodeListEntry.ts @@ -0,0 +1,146 @@ +import type { Episode } from "src/types/Episode"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { getEpisodeKey } from "src/utility/episodeKey"; +import { + getFinishedPlayedEpisodeRecords, + getPlayedEpisodeRecordKey, + type PlayedEpisodeMap, +} from "src/utility/episodeStatus"; + +export interface EpisodeListEntry { + episode: Episode; + isAvailable: boolean; + unavailableReason?: string; +} + +export interface PlayedEpisodeListEntry extends EpisodeListEntry { + playedEpisode: PlayedEpisode; + playedEpisodeKey: string; +} + +export function createEpisodeListEntry(episode: Episode): EpisodeListEntry { + return { + episode, + isAvailable: true, + }; +} + +export function createEpisodeListEntries( + episodes: Episode[], +): EpisodeListEntry[] { + return episodes.map(createEpisodeListEntry); +} + +export function buildPlayedEpisodeListEntries( + playedEpisodes: PlayedEpisodeMap, + episodeSources: Episode[][], +): PlayedEpisodeListEntry[] { + const episodeLookup = buildEpisodeLookup(episodeSources.flat()); + + return getFinishedPlayedEpisodeRecords(playedEpisodes) + .map(({ key, episode }) => + createPlayedEpisodeListEntry(key, episode, episodeLookup), + ) + .sort(comparePlayedEpisodeEntries); +} + +function createPlayedEpisodeListEntry( + key: string, + playedEpisode: PlayedEpisode, + episodeLookup: Map, +): PlayedEpisodeListEntry { + const episode = resolvePlayedEpisode(key, playedEpisode, episodeLookup); + + if (episode) { + return { + episode, + isAvailable: true, + playedEpisode, + playedEpisodeKey: key, + }; + } + + return { + episode: createPlayedEpisodePlaceholder(playedEpisode), + isAvailable: false, + unavailableReason: "Unavailable in current feeds", + playedEpisode, + playedEpisodeKey: key, + }; +} + +function resolvePlayedEpisode( + key: string, + playedEpisode: PlayedEpisode, + episodeLookup: Map, +): Episode | undefined { + const lookupKeys = [ + key, + getPlayedEpisodeRecordKey(playedEpisode), + playedEpisode.title, + ]; + + for (const lookupKey of lookupKeys) { + const episode = episodeLookup.get(lookupKey); + if (episode) return episode; + } + + return undefined; +} + +function buildEpisodeLookup(episodes: Episode[]): Map { + const lookup = new Map(); + + for (const episode of episodes) { + const keys = [ + getEpisodeKey(episode), + episode.podcastName ? `${episode.podcastName}::${episode.title}` : "", + episode.title, + ].filter(Boolean); + + for (const key of keys) { + if (!lookup.has(key)) { + lookup.set(key, episode); + } + } + } + + return lookup; +} + +export function createPlayedEpisodePlaceholder( + playedEpisode: Pick, +): Episode { + return { + title: playedEpisode.title, + podcastName: playedEpisode.podcastName, + streamUrl: "", + url: "", + description: "", + content: "", + }; +} + +function comparePlayedEpisodeEntries( + a: PlayedEpisodeListEntry, + b: PlayedEpisodeListEntry, +): number { + if (a.isAvailable !== b.isAvailable) { + return a.isAvailable ? -1 : 1; + } + + const aDate = getEpisodeTimestamp(a.episode); + const bDate = getEpisodeTimestamp(b.episode); + if (aDate !== bDate) { + return bDate - aDate; + } + + return a.episode.title.localeCompare(b.episode.title); +} + +function getEpisodeTimestamp(episode: Episode): number { + if (!episode.episodeDate) return 0; + + const timestamp = Number(new Date(episode.episodeDate)); + return Number.isNaN(timestamp) ? 0 : timestamp; +} diff --git a/src/utility/episodeStatus.test.ts b/src/utility/episodeStatus.test.ts new file mode 100644 index 0000000..c9c9add --- /dev/null +++ b/src/utility/episodeStatus.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import type { Episode } from "src/types/Episode"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { + getFinishedPlayedEpisodeRecords, + getPlayedEpisode, + isEpisodeFinished, +} from "./episodeStatus"; + +const episode: Episode = { + title: "Shared title", + streamUrl: "https://example.com/audio.mp3", + url: "https://example.com/episode", + description: "", + content: "", + podcastName: "Design Podcast", +}; + +function playedEpisode( + podcastName: string, + finished: boolean, +): PlayedEpisode { + return { + title: episode.title, + podcastName, + time: finished ? 120 : 30, + duration: 120, + finished, + }; +} + +describe("episodeStatus", () => { + test("prefers composite played keys over legacy title keys", () => { + const playedEpisodes = { + [episode.title]: playedEpisode("Other Podcast", true), + "Design Podcast::Shared title": playedEpisode("Design Podcast", false), + }; + + expect(getPlayedEpisode(playedEpisodes, episode)).toMatchObject({ + podcastName: "Design Podcast", + finished: false, + }); + expect(isEpisodeFinished(episode, playedEpisodes)).toBe(false); + }); + + test("falls back to legacy title keys", () => { + const playedEpisodes = { + [episode.title]: playedEpisode("Design Podcast", true), + }; + + expect(isEpisodeFinished(episode, playedEpisodes)).toBe(true); + }); + + test("lists only finished played records", () => { + const records = getFinishedPlayedEpisodeRecords({ + finished: playedEpisode("Design Podcast", true), + unfinished: playedEpisode("Design Podcast", false), + }); + + expect(records).toHaveLength(1); + expect(records[0].key).toBe("finished"); + }); + + test("deduplicates legacy and composite entries for the same played episode", () => { + const records = getFinishedPlayedEpisodeRecords({ + [episode.title]: playedEpisode("Design Podcast", true), + "Design Podcast::Shared title": playedEpisode("Design Podcast", true), + }); + + expect(records).toHaveLength(1); + expect(records[0].key).toBe("Design Podcast::Shared title"); + }); +}); diff --git a/src/utility/episodeStatus.ts b/src/utility/episodeStatus.ts new file mode 100644 index 0000000..7f9f077 --- /dev/null +++ b/src/utility/episodeStatus.ts @@ -0,0 +1,65 @@ +import type { Episode } from "src/types/Episode"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { getEpisodeKey } from "src/utility/episodeKey"; + +export type PlayedEpisodeMap = Record; + +export interface PlayedEpisodeRecord { + key: string; + episode: PlayedEpisode; +} + +export function getPlayedEpisode( + playedEpisodes: PlayedEpisodeMap, + episode: Episode | null | undefined, +): PlayedEpisode | undefined { + if (!episode) return undefined; + + const key = getEpisodeKey(episode); + if (key && playedEpisodes[key]) { + return playedEpisodes[key]; + } + + if (episode.title && playedEpisodes[episode.title]) { + return playedEpisodes[episode.title]; + } + + return undefined; +} + +export function isEpisodeFinished( + episode: Episode | null | undefined, + playedEpisodes: PlayedEpisodeMap, +): boolean { + return getPlayedEpisode(playedEpisodes, episode)?.finished ?? false; +} + +export function getPlayedEpisodeRecordKey(episode: PlayedEpisode): string { + if (episode.podcastName) { + return `${episode.podcastName}::${episode.title}`; + } + + return episode.title; +} + +export function getFinishedPlayedEpisodeRecords( + playedEpisodes: PlayedEpisodeMap, +): PlayedEpisodeRecord[] { + const recordsByEpisodeKey = new Map(); + + for (const [key, episode] of Object.entries(playedEpisodes)) { + if (!episode.finished) continue; + + const episodeKey = getPlayedEpisodeRecordKey(episode); + const existingRecord = recordsByEpisodeKey.get(episodeKey); + if (!existingRecord || isCompositePlayedKey(key)) { + recordsByEpisodeKey.set(episodeKey, { key, episode }); + } + } + + return Array.from(recordsByEpisodeKey.values()); +} + +function isCompositePlayedKey(key: string): boolean { + return key.includes("::"); +} From cfef44c3bbc66cf20cd30c770d02e0f554274ce4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 3 May 2026 12:05:54 +0200 Subject: [PATCH 2/2] codex: address PR review feedback (#170) --- src/store/index.test.ts | 68 +++++++++++++++++++ src/store/index.ts | 61 +++++++++++------ .../PodcastView.integration.test.ts | 59 ++++++++++++++++ src/ui/PodcastView/PodcastView.svelte | 18 ++++- src/utility/episodeStatus.test.ts | 21 ++++++ src/utility/episodeStatus.ts | 35 +++++++++- 6 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 src/store/index.test.ts diff --git a/src/store/index.test.ts b/src/store/index.test.ts new file mode 100644 index 0000000..6a21bdc --- /dev/null +++ b/src/store/index.test.ts @@ -0,0 +1,68 @@ +import { get } from "svelte/store"; +import { beforeEach, describe, expect, test } from "vitest"; + +import type { Episode } from "src/types/Episode"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { playedEpisodes } from "./index"; + +const episode: Episode = { + title: "Shared title", + streamUrl: "https://example.com/audio.mp3", + url: "https://example.com/episode", + description: "", + content: "", + podcastName: "Design Podcast", +}; + +function playedEpisode(podcastName: string): PlayedEpisode { + return { + title: episode.title, + podcastName, + time: 120, + duration: 120, + finished: true, + }; +} + +describe("playedEpisodes store", () => { + beforeEach(() => { + playedEpisodes.set({}); + }); + + test("markAsUnplayed clears composite and legacy aliases", () => { + playedEpisodes.set({ + [episode.title]: playedEpisode("Design Podcast"), + "Design Podcast::Shared title": playedEpisode("Design Podcast"), + "Other Podcast::Shared title": playedEpisode("Other Podcast"), + }); + + playedEpisodes.markAsUnplayed(episode); + + const stored = get(playedEpisodes); + expect(stored[episode.title]).toMatchObject({ + finished: false, + time: 0, + }); + expect(stored["Design Podcast::Shared title"]).toMatchObject({ + finished: false, + time: 0, + }); + expect(stored["Other Podcast::Shared title"]).toMatchObject({ + finished: true, + time: 120, + }); + }); + + test("markKeyAsUnplayed clears aliases for the keyed played episode", () => { + playedEpisodes.set({ + [episode.title]: playedEpisode("Design Podcast"), + "Design Podcast::Shared title": playedEpisode("Design Podcast"), + }); + + playedEpisodes.markKeyAsUnplayed("Design Podcast::Shared title"); + + const stored = get(playedEpisodes); + expect(stored[episode.title]?.finished).toBe(false); + expect(stored["Design Podcast::Shared title"]?.finished).toBe(false); + }); +}); diff --git a/src/store/index.ts b/src/store/index.ts index e20ae34..7087575 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,7 +9,10 @@ import type DownloadedEpisode from "src/types/DownloadedEpisode"; import { TFile } from "obsidian"; import type { LocalEpisode } from "src/types/LocalEpisode"; import { getEpisodeKey } from "src/utility/episodeKey"; -import { getPlayedEpisode } from "src/utility/episodeStatus"; +import { + getPlayedEpisode, + getPlayedEpisodeAliasKeys, +} from "src/utility/episodeStatus"; export const plugin = writable(); export const currentTime = writable(0); @@ -114,18 +117,14 @@ export const playedEpisodes = (() => { const key = getEpisodeKey(episode); if (!key) return playedEpisodes; - const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || { - title: episode.title, - podcastName: episode.podcastName, - time: 0, - duration: 0, - finished: false, - }; - - playedEpisode.time = 0; - playedEpisode.finished = false; - - playedEpisodes[key] = playedEpisode; + markPlayedEpisodeAliasesAsUnplayed( + playedEpisodes, + { + title: episode.title, + podcastName: episode.podcastName, + }, + key, + ); return playedEpisodes; }); }, @@ -136,18 +135,42 @@ export const playedEpisodes = (() => { const playedEpisode = playedEpisodes[key]; if (!playedEpisode) return playedEpisodes; - playedEpisodes[key] = { - ...playedEpisode, - time: 0, - finished: false, - }; - + markPlayedEpisodeAliasesAsUnplayed(playedEpisodes, playedEpisode, key); return playedEpisodes; }); }, }; })(); +function markPlayedEpisodeAliasesAsUnplayed( + playedEpisodeMap: { [key: string]: PlayedEpisode }, + episode: Pick, + preferredKey: string, +) { + const aliasKeys = getPlayedEpisodeAliasKeys( + playedEpisodeMap, + episode, + preferredKey, + ); + const keysToUpdate = aliasKeys.length > 0 ? aliasKeys : [preferredKey]; + + for (const key of keysToUpdate) { + const playedEpisode = playedEpisodeMap[key] || { + title: episode.title, + podcastName: episode.podcastName, + time: 0, + duration: 0, + finished: false, + }; + + playedEpisodeMap[key] = { + ...playedEpisode, + time: 0, + finished: false, + }; + } +} + export const podcastsUpdated = writable(0); export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({}); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 893b621..3e04885 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -266,4 +266,63 @@ describe("PodcastView integration flow", () => { expect(await screen.findByText("Already Finished")).toBeInTheDocument(); expect(screen.getByText("Played")).toBeInTheDocument(); }); + + test("does not apply a delayed played feed refresh after leaving the played view", async () => { + const playedEpisode: Episode = { + title: "Already Finished", + streamUrl: "https://pod.example.com/finished.mp3", + url: "https://pod.example.com/finished", + description: "Finished episode description", + content: "

Finished episode content

", + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date("2023-01-15T00:00:00.000Z"), + }; + let resolvePlayedFetch!: (value: Episode[]) => void; + + mockGetEpisodes + .mockResolvedValueOnce([testEpisode]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePlayedFetch = resolve; + }), + ); + playedEpisodes.set({ + [`${testFeed.title}::${playedEpisode.title}`]: { + title: playedEpisode.title, + podcastName: testFeed.title, + time: 100, + duration: 100, + finished: true, + }, + }); + plugin.set({ + settings: { + feedCache: { + enabled: false, + ttlHours: 6, + }, + }, + } as never); + + render(PodcastView); + + const playedCard = await screen.findByLabelText("Played"); + await waitFor(() => expect(mockGetEpisodes).toHaveBeenCalledTimes(1)); + episodeCache.set({}); + + await fireEvent.click(playedCard); + expect(await screen.findByText("Played")).toBeInTheDocument(); + + await fireEvent.click( + screen.getByRole("button", { name: /latest episodes/i }), + ); + expect(screen.queryByText("Already Finished")).not.toBeInTheDocument(); + + resolvePlayedFetch([testEpisode]); + + expect(await screen.findByText(testEpisode.title)).toBeInTheDocument(); + expect(screen.queryByText("Already Finished")).not.toBeInTheDocument(); + }); }); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 7dbadf3..3cb5c8c 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -251,6 +251,20 @@ displayedEpisodes = displayedEpisodeEntries.map((entry) => entry.episode); } + function isPlayedPlaylistSelected() { + return ( + isShowingPlayedEpisodes && + selectedPlaylist?.isVirtual && + selectedPlaylist.name === PLAYED_SETTINGS.name + ); + } + + function updateDisplayedPlayedEpisodesIfSelected() { + if (!isPlayedPlaylistSelected()) return; + + updateDisplayedPlayedEpisodes(); + } + function showLatestEpisodes() { selectedFeed = null; selectedPlaylist = null; @@ -364,7 +378,7 @@ async function handleClickRefresh() { if (isShowingPlayedEpisodes) { await fetchEpisodesInAllFeeds(feeds, false); - updateDisplayedPlayedEpisodes(); + updateDisplayedPlayedEpisodesIfSelected(); return; } @@ -424,7 +438,7 @@ viewState.set(ViewState.EpisodeList); void fetchEpisodesInAllFeeds(feeds).then(() => { - updateDisplayedPlayedEpisodes(); + updateDisplayedPlayedEpisodesIfSelected(); }); return; } diff --git a/src/utility/episodeStatus.test.ts b/src/utility/episodeStatus.test.ts index c9c9add..cf96897 100644 --- a/src/utility/episodeStatus.test.ts +++ b/src/utility/episodeStatus.test.ts @@ -5,6 +5,7 @@ import type { PlayedEpisode } from "src/types/PlayedEpisode"; import { getFinishedPlayedEpisodeRecords, getPlayedEpisode, + getPlayedEpisodeAliasKeys, isEpisodeFinished, } from "./episodeStatus"; @@ -71,4 +72,24 @@ describe("episodeStatus", () => { expect(records).toHaveLength(1); expect(records[0].key).toBe("Design Podcast::Shared title"); }); + + test("finds stored aliases for the same played episode", () => { + const aliases = getPlayedEpisodeAliasKeys( + { + [episode.title]: playedEpisode("Design Podcast", true), + "Design Podcast::Shared title": playedEpisode("Design Podcast", true), + "Other Podcast::Shared title": playedEpisode("Other Podcast", true), + }, + { + title: episode.title, + podcastName: episode.podcastName, + }, + "Design Podcast::Shared title", + ); + + expect(aliases).toEqual([ + episode.title, + "Design Podcast::Shared title", + ]); + }); }); diff --git a/src/utility/episodeStatus.ts b/src/utility/episodeStatus.ts index 7f9f077..c8d45c6 100644 --- a/src/utility/episodeStatus.ts +++ b/src/utility/episodeStatus.ts @@ -34,7 +34,9 @@ export function isEpisodeFinished( return getPlayedEpisode(playedEpisodes, episode)?.finished ?? false; } -export function getPlayedEpisodeRecordKey(episode: PlayedEpisode): string { +export function getPlayedEpisodeRecordKey( + episode: Pick, +): string { if (episode.podcastName) { return `${episode.podcastName}::${episode.title}`; } @@ -42,6 +44,27 @@ export function getPlayedEpisodeRecordKey(episode: PlayedEpisode): string { return episode.title; } +export function getPlayedEpisodeAliasKeys( + playedEpisodes: PlayedEpisodeMap, + episode: Pick, + sourceKey?: string, +): string[] { + const keys = new Set(); + const targetKey = getPlayedEpisodeRecordKey(episode); + + for (const [key, playedEpisode] of Object.entries(playedEpisodes)) { + if ( + key === sourceKey || + key === targetKey || + isSamePlayedEpisode(playedEpisode, episode) + ) { + keys.add(key); + } + } + + return Array.from(keys); +} + export function getFinishedPlayedEpisodeRecords( playedEpisodes: PlayedEpisodeMap, ): PlayedEpisodeRecord[] { @@ -63,3 +86,13 @@ export function getFinishedPlayedEpisodeRecords( function isCompositePlayedKey(key: string): boolean { return key.includes("::"); } + +function isSamePlayedEpisode( + playedEpisode: PlayedEpisode, + episode: Pick, +): boolean { + if (playedEpisode.title !== episode.title) return false; + if (!playedEpisode.podcastName || !episode.podcastName) return true; + + return playedEpisode.podcastName === episode.podcastName; +}