Skip to content

Commit 9ebdb84

Browse files
committed
fix(podcastview): search, refresh, and feed-load feedback
- make Refresh actually refresh all feeds in the Latest Episodes view - avoid duplicate loading indicators for a selected feed - treat a whitespace-only search as empty and live-update an open playlist - surface feed-load and search failures (invalid feed URL, iTunes errors) to the user instead of failing silently
1 parent 93553b7 commit 9ebdb84

3 files changed

Lines changed: 149 additions & 26 deletions

File tree

‎src/iTunesAPIConsumer.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PodcastFeed } from "./types/PodcastFeed";
2-
import { requestWithTimeout, NetworkError } from "./utility/networkRequest";
2+
import { requestWithTimeout } from "./utility/networkRequest";
33

44
interface iTunesResult {
55
collectionName: string;
@@ -30,9 +30,13 @@ export async function queryiTunesPodcasts(query: string): Promise<PodcastFeed[]>
3030
collectionId: d.collectionId,
3131
}));
3232
} catch (error) {
33-
if (error instanceof NetworkError) {
34-
console.error(`iTunes search failed: ${error.message}`);
35-
}
36-
return [];
33+
// Log every failure (including a malformed-JSON SyntaxError from
34+
// response.json), not just NetworkError, so swallowed errors are
35+
// diagnosable, then rethrow so the caller can distinguish a genuine
36+
// failure from a legitimate empty result set (SA-01). Returning [] here
37+
// collapsed both into the benign "No results." message.
38+
const message = error instanceof Error ? error.message : String(error);
39+
console.error(`iTunes search failed: ${message}`);
40+
throw error instanceof Error ? error : new Error(message);
3741
}
3842
}

‎src/ui/PodcastView/PodcastView.svelte‎

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,15 @@
6363
});
6464
6565
$: loadingFeedNames = Array.from(loadingFeeds);
66+
// Exclude the selected feed from the banner: its loading state is already
67+
// shown by the inline EpisodeList spinner, so the banner would duplicate it.
68+
$: bannerFeedNames = loadingFeedNames.filter(
69+
(name) => name !== selectedFeed?.title,
70+
);
6671
$: loadingFeedSummary =
67-
loadingFeedNames.length > 3
68-
? `${loadingFeedNames.slice(0, 3).join(", ")} +${loadingFeedNames.length - 3} more`
69-
: loadingFeedNames.join(", ");
72+
bannerFeedNames.length > 3
73+
? `${bannerFeedNames.slice(0, 3).join(", ")} +${bannerFeedNames.length - 3} more`
74+
: bannerFeedNames.join(", ");
7075
$: isFetchingEpisodes = loadingFeedNames.length > 0;
7176
7277
onMount(() => {
@@ -89,11 +94,19 @@
8994
];
9095
};
9196
97+
// Refresh both the grid tiles AND an open playlist's episode list so a
98+
// context-menu add/remove updates the currently-viewed list immediately
99+
// (PL-04).
100+
const refreshPlaylists = () => {
101+
updateDisplayedPlaylists();
102+
updateDisplayedPlaylistEpisodesIfSelected();
103+
};
104+
92105
const playlistUnsubscribers = [
93-
playlists.subscribe(updateDisplayedPlaylists),
106+
playlists.subscribe(refreshPlaylists),
94107
queue.subscribe(updateDisplayedPlaylists),
95-
favorites.subscribe(updateDisplayedPlaylists),
96-
localFiles.subscribe(updateDisplayedPlaylists),
108+
favorites.subscribe(refreshPlaylists),
109+
localFiles.subscribe(refreshPlaylists),
97110
// Recompute when the plugin store re-emits so toggling the autoQueue
98111
// setting hides/shows the empty Queue tile immediately (issue #108).
99112
plugin.subscribe(updateDisplayedPlaylists),
@@ -179,6 +192,7 @@
179192
async function fetchEpisodes(
180193
feed: PodcastFeed,
181194
useCache: boolean = true,
195+
notifyOnError: boolean = false,
182196
): Promise<Episode[]> {
183197
const cacheEnabled = isFeedCacheEnabled();
184198
const cacheTtlMs = getFeedCacheTtlMs();
@@ -247,6 +261,15 @@
247261
}
248262
}
249263
264+
// No cache/download fallback recovered episodes. Surface a Notice
265+
// only for the interactive single-feed path; the bulk/background
266+
// path stays quiet to avoid spamming on refresh or initial load.
267+
if (notifyOnError) {
268+
new Notice(
269+
`Could not load episodes for ${feed.title}. Check your connection and try again.`,
270+
);
271+
}
272+
250273
return [];
251274
}
252275
}
@@ -261,7 +284,10 @@
261284
return feeds.filter((feed) => playedPodcastNames.has(feed.title));
262285
}
263286
264-
async function fetchFullEpisodes(feed: PodcastFeed): Promise<Episode[]> {
287+
async function fetchFullEpisodes(
288+
feed: PodcastFeed,
289+
notifyOnError: boolean = false,
290+
): Promise<Episode[]> {
265291
const cacheEnabled = isFeedCacheEnabled();
266292
const persistedEpisodes = cacheEnabled
267293
? getCachedEpisodes(feed, getFeedCacheTtlMs())
@@ -272,22 +298,23 @@
272298
return inMemoryEpisodes;
273299
}
274300
275-
return fetchEpisodes(feed, false);
301+
return fetchEpisodes(feed, false, notifyOnError);
276302
}
277303
278304
async function fetchEpisodesByStrategy(
279305
feed: PodcastFeed,
280306
strategy: EpisodeFetchStrategy = "cached",
307+
notifyOnError: boolean = false,
281308
): Promise<Episode[]> {
282309
if (strategy === "network") {
283-
return fetchEpisodes(feed, false);
310+
return fetchEpisodes(feed, false, notifyOnError);
284311
}
285312
286313
if (strategy === "full") {
287-
return fetchFullEpisodes(feed);
314+
return fetchFullEpisodes(feed, notifyOnError);
288315
}
289316
290-
return fetchEpisodes(feed, true);
317+
return fetchEpisodes(feed, true, notifyOnError);
291318
}
292319
293320
function getPlayedPlaylist(): Playlist {
@@ -361,6 +388,37 @@
361388
updateDisplayedPlayedEpisodes();
362389
}
363390
391+
// Keep an OPEN playlist's episode list in sync when its backing store changes
392+
// (e.g. a context-menu add/remove). Without this the list view only reflected
393+
// the snapshot taken at click time and went stale until the user navigated
394+
// away and back (PL-04). The virtual Played list and the Queue (which routes
395+
// to the player) are handled elsewhere, so they are skipped here.
396+
function updateDisplayedPlaylistEpisodesIfSelected() {
397+
if (!selectedPlaylist || selectedPlaylist.isVirtual) return;
398+
399+
const name = selectedPlaylist.name;
400+
if (name === get(queue).name) return;
401+
402+
let live: Playlist | undefined;
403+
if (name === get(favorites).name) live = get(favorites);
404+
else if (name === get(localFiles).name) live = get(localFiles);
405+
else {
406+
live = get(playlists)[name];
407+
if (!live) {
408+
// A custom playlist deleted while open: fall back to Latest Episodes.
409+
showLatestEpisodes();
410+
return;
411+
}
412+
}
413+
414+
if (!live) return;
415+
416+
selectedPlaylist = live;
417+
displayedEpisodes = currentSearchQuery
418+
? searchEpisodes(currentSearchQuery, live.episodes)
419+
: live.episodes;
420+
}
421+
364422
function showLatestEpisodes() {
365423
selectedFeed = null;
366424
selectedPlaylist = null;
@@ -428,7 +486,7 @@
428486
setFeedLoading(feed.title, true);
429487
430488
try {
431-
const episodes = await fetchFullEpisodes(feed);
489+
const episodes = await fetchFullEpisodes(feed, true);
432490
displayedEpisodes = currentSearchQuery
433491
? searchEpisodes(currentSearchQuery, episodes)
434492
: episodes;
@@ -481,6 +539,15 @@
481539
return;
482540
}
483541
542+
// Latest Episodes view (no feed and no playlist selected): refresh all
543+
// feeds over the network so the aggregated list actually updates. The
544+
// latestEpisodes readable + its subscriber repopulate displayedEpisodes,
545+
// and setFeedLoading inside fetchEpisodesInAllFeeds drives the banner.
546+
if (!selectedFeed && !selectedPlaylist) {
547+
await fetchEpisodesInAllFeeds(feeds, "network");
548+
return;
549+
}
550+
484551
if (!selectedFeed) return;
485552
486553
setFeedLoading(selectedFeed.title, true);
@@ -489,6 +556,7 @@
489556
const episodes = await fetchEpisodesByStrategy(
490557
selectedFeed,
491558
"network",
559+
true,
492560
);
493561
displayedEpisodeEntries = null;
494562
displayedEpisodes = currentSearchQuery
@@ -582,14 +650,14 @@
582650
{#if $viewState === ViewState.Player}
583651
<EpisodePlayer />
584652
{:else if $viewState === ViewState.EpisodeList}
585-
{#if loadingFeedNames.length > 0}
653+
{#if bannerFeedNames.length > 0}
586654
<div class="feed-loading-banner">
587655
<div class="feed-loading-spinner">
588656
<Icon icon="loader-2" size={18} clickable={false} />
589657
</div>
590658
<div class="feed-loading-text">
591659
<span>
592-
Updating {loadingFeedNames.length} feed{loadingFeedNames.length === 1 ? "" : "s"}
660+
Updating {bannerFeedNames.length} feed{bannerFeedNames.length === 1 ? "" : "s"}
593661
</span>
594662
{#if loadingFeedSummary}
595663
<span class="feed-loading-names">{loadingFeedSummary}</span>

‎src/ui/settings/PodcastQueryGrid.svelte‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { debounce } from "obsidian";
2+
import { debounce, Notice } from "obsidian";
33
import { queryiTunesPodcasts } from "src/iTunesAPIConsumer";
44
import FeedParser from "src/parser/feedParser";
55
import { savedFeeds, podcastsUpdated } from "src/store";
@@ -13,6 +13,8 @@
1313
let searchResults: PodcastFeed[] = [];
1414
let gridSizeClass: string = "grid-3";
1515
let searchQuery: string = "";
16+
let isSearching: boolean = false;
17+
let searchError: string = "";
1618
1719
let searchInput: HTMLInputElement;
1820
@@ -50,17 +52,42 @@
5052
}
5153
5254
const debouncedUpdate = debounce(
53-
async ({detail: { value }}: CustomEvent<{ value: string }>) => {
55+
async ({detail: { value }}: CustomEvent<{ value: string }>) => {
5456
searchQuery = value;
57+
searchError = "";
5558
const customFeedUrl = checkStringIsUrl(value);
56-
57-
if (customFeedUrl) {
58-
const feed = await (new FeedParser().getFeed(customFeedUrl.href));
59-
searchResults = [feed];
59+
60+
// Only treat the input as a feed URL when it is an http(s) URL, so
61+
// things like "podcast:name" don't get parsed as feeds.
62+
const isFeedUrl =
63+
customFeedUrl?.protocol === "http:" ||
64+
customFeedUrl?.protocol === "https:";
65+
66+
if (isFeedUrl && customFeedUrl) {
67+
isSearching = true;
68+
try {
69+
const feed = await new FeedParser().getFeed(customFeedUrl.href);
70+
searchResults = [feed];
71+
} catch (e) {
72+
searchResults = [];
73+
const msg = e instanceof Error ? e.message : String(e);
74+
searchError = `Could not load feed: ${msg}`;
75+
new Notice(searchError);
76+
} finally {
77+
isSearching = false;
78+
}
6079
} else if (value.trim() === "") {
6180
updateSearchResults();
6281
} else {
63-
searchResults = await queryiTunesPodcasts(value);
82+
isSearching = true;
83+
try {
84+
searchResults = await queryiTunesPodcasts(value);
85+
} catch (e) {
86+
searchResults = [];
87+
searchError = "Could not search podcasts. Please try again.";
88+
} finally {
89+
isSearching = false;
90+
}
6491
}
6592
},
6693
300,
@@ -95,6 +122,20 @@
95122
bind:el={searchInput}
96123
/>
97124

125+
{#if isSearching}
126+
<div class="podcast-query-status" role="status" aria-live="polite">
127+
Searching...
128+
</div>
129+
{:else if searchError}
130+
<div class="podcast-query-status podcast-query-error" role="alert">
131+
{searchError}
132+
</div>
133+
{:else if searchQuery.trim() !== "" && searchResults.length === 0}
134+
<div class="podcast-query-status" role="status" aria-live="polite">
135+
No results.
136+
</div>
137+
{/if}
138+
98139
<div class="podcast-query-results" role="list" aria-label="Podcast search results">
99140
{#each searchResults as podcast (podcast.url)}
100141
<div role="listitem">
@@ -114,6 +155,16 @@
114155
margin-bottom: 1.5rem;
115156
}
116157
158+
.podcast-query-status {
159+
margin-bottom: 0.75rem;
160+
font-size: 0.85rem;
161+
color: var(--text-muted);
162+
}
163+
164+
.podcast-query-error {
165+
color: var(--text-error);
166+
}
167+
117168
.podcast-query-results {
118169
display: grid;
119170
gap: 0.75rem;

0 commit comments

Comments
 (0)