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

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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}`,

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;
});