diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index 79bf477..ab846a8 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -82,6 +82,16 @@ export const getRSSItems = (xml: string): string[] => { return channel.match(//gi) ?? [] } +/** Channel-level artwork: `` (podcasts) or RSS 2.0 + * ``. Exported so the feed store can backfill a feed's + * coverUrl on refresh without re-deriving the channel block. */ +export const parseChannelCoverUrl = (channel: string): string | undefined => { + const itunesHref = getAttr(channel, "itunes:image", "href") + if (itunesHref) return itunesHref + const url = getTagValue(channel, "image").match(/([\s\S]*?)<\/url>/i)?.[1] + return url?.trim() || undefined +} + /** Parse a single `` into an Episode. Exported so the feed store can * parse large feeds in bounded chunks (yielding to the event loop between * chunks) instead of one synchronous block. */ @@ -158,6 +168,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes feedUrl, lastUpdated, isSubscribed: true, + coverUrl: parseChannelCoverUrl(channel), episodes, } } diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index bac31d0..d8441ad 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -56,6 +56,7 @@ import { import type { Episode, Progress } from "../types/episode"; import type { Feed } from "../types/feed"; import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; +import { useDownloadStore } from "../stores/download"; import { useFeedStore } from "../stores/feed"; export interface AudioControls { @@ -315,13 +316,18 @@ async function play(episode: Episode): Promise { const feedStore = useFeedStore(); const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; + // Play the downloaded file when present (offline + no network stalls); + // otherwise stream. Cover resolves to the feed art, falling back to the + // episode's own image (feeds added by URL may lack a channel cover). + const downloadStore = useDownloadStore(); + const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; + const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; // Cover art only applies at file LOAD (the runtime video-add fallback // never becomes an albumart track), so a cold-cache play must wait for // the fetch or play artless. Serve the disk cache synchronously; on a // miss, await the single-flight fetch with a 1.2s cap (covers fetch in // ~300ms typically) — past the cap, play bare and let the fetch warm // the cache for next time. - const coverUrl = feed?.podcast.coverUrl; let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; if (coverUrl && !coverArtPath) { const path = await Promise.race([ @@ -338,7 +344,7 @@ async function play(episode: Episode): Promise { startPos = savedProgress.position; } - await b.play(episode.audioUrl, { + await b.play(url, { volume: vol, speed: spd, startPosition: startPos > 0 ? startPos : undefined, @@ -421,17 +427,20 @@ async function load(episode: Episode): Promise { // fills its demuxer cache while parked, so the user's first Play flips // `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget // — a failed preload just makes the first play take the cold path. + const downloadStore = useDownloadStore(); + const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; if (episode.audioUrl && backend) { // The preload must carry the cover AT LOAD: cover-art-files only // applies when the file loads, and the runtime video-add fallback // never becomes an albumart track (verified). Restore already waits // on feeds/progress at boot, so the bounded fetch (~300ms typical, - // 8s worst case) is free. - const coverUrl = feed?.podcast.coverUrl; + // 8s worst case) is free. Falls back to the episode's own image when + // the feed has no channel cover. + const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null; const backendSnap = backend; backendSnap - .preload(episode.audioUrl, { + .preload(url, { volume: volume(), speed: storeSpeed || speed(), startPosition: pos > 0 ? pos : undefined, @@ -603,9 +612,11 @@ async function switchBackend(name: BackendName): Promise { .feeds() .find((f) => f.podcast.id === ep.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; - const coverUrl = feed?.podcast.coverUrl; + const url = + useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl; + const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl; const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; - await backend.play(ep.audioUrl, { + await backend.play(url, { startPosition: pos, volume: vol, speed: spd, diff --git a/src/index.tsx b/src/index.tsx index 4a4ac13..9235826 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -186,9 +186,13 @@ async function handlePlay(feeds: Feed[], arg: string): Promise { const backend = createAudioBackend() if (episodeResult.audioUrl) { // Stage the podcast cover so the system Now Playing shows - // artwork (mpv --cover-art-files), like the UI path does. - const coverArtPath = feedResult.podcast.coverUrl - ? await fetchCoverArt(feedResult.podcast.coverUrl) + // artwork (mpv --cover-art-files), like the UI path does. Falls + // back to the episode's own image when the feed has no channel + // cover (URL-added feeds). + const coverUrl = + feedResult.podcast.coverUrl ?? episodeResult.imageUrl; + const coverArtPath = coverUrl + ? await fetchCoverArt(coverUrl) : null await backend.play(episodeResult.audioUrl, { mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 28ded69..a5035df 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -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 => { + ): 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(//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; });