fix(cover+downloads): channel art parsing, episode image fallback, local playback

The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
  parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
  refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
  so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
  (getDownloadedFilePath) over the stream URL — downloaded episodes now
  play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
This commit is contained in:
2026-08-12 10:52:54 -04:00
parent 0b4a551744
commit 13664c3cec
4 changed files with 72 additions and 23 deletions

View File

@@ -10,7 +10,7 @@ import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { getRSSItems, parseRSSItem } from "../api/rss-parser";
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import { mergeEpisodes } from "../utils/episode-merge";
@@ -324,14 +324,16 @@ function createFeedStore() {
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Returns NULL when the feed could not be fetched (network error, non-OK
* response, timeout) — callers must treat null as "unchanged" and keep
* the previously loaded episodes. A failed refresh must never look like
* an empty feed, or the store would wipe a subscribed show's episodes. */
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Also returns the channel-level artwork so callers can backfill a feed's
* coverUrl (subscribe + refresh). Null episodes on any failure — a
* failed fetch must not look like an empty feed, or the store would wipe
* a subscribed show's episodes. */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[] | null> => {
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
try {
const response = await fetch(feedUrl, {
headers: {
@@ -342,8 +344,9 @@ function createFeedStore() {
// refresh loop) indefinitely.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
if (!response.ok) return { episodes: null, coverUrl: undefined };
const xml = await response.text();
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml;
const allEpisodes = sortEpisodesReverseChronological(
await parseEpisodesIncremental(xml, feedUrl),
);
@@ -354,9 +357,12 @@ function createFeedStore() {
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
}
return allEpisodes.slice(0, limit);
return {
episodes: allEpisodes.slice(0, limit),
coverUrl: parseChannelCoverUrl(channel),
};
} catch {
return null;
return { episodes: null, coverUrl: undefined };
}
};
@@ -393,11 +399,14 @@ function createFeedStore() {
}
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
const { episodes, coverUrl } = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
if (!podcast.coverUrl && coverUrl) {
podcast = { ...podcast, coverUrl };
}
const newFeed: Feed = {
id: feedId,
podcast,
@@ -482,7 +491,7 @@ function createFeedStore() {
return activity.track((async () => {
const feed = getFeed(feedId);
if (!feed) return;
const episodes = await fetchEpisodes(
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
@@ -490,7 +499,14 @@ function createFeedStore() {
// Fetch failed (null): keep the currently loaded episodes untouched.
if (!episodes) return;
setFeeds((prev) => {
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
let updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feedId && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) scheduleSaveFeeds();
return updated;
});
@@ -515,7 +531,7 @@ function createFeedStore() {
feeds(),
FETCH_CONCURRENCY,
async (feed) => {
const episodes = await fetchEpisodes(
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
@@ -523,7 +539,14 @@ function createFeedStore() {
// A failed fetch (null) leaves that feed untouched.
if (!episodes) return;
setFeeds((prev) => {
const updated = applyRefreshedEpisodes(prev, feed.id, episodes);
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feed.id && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) scheduleSaveFeeds();
return updated;
});