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:
@@ -82,6 +82,16 @@ export const getRSSItems = (xml: string): string[] => {
|
||||
return channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||
}
|
||||
|
||||
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||
* `<image><url>`. 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(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||
return url?.trim() || undefined
|
||||
}
|
||||
|
||||
/** Parse a single `<item>` 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
.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,
|
||||
|
||||
@@ -186,9 +186,13 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||
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}`,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user