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) ?? []
|
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 a single `<item>` into an Episode. Exported so the feed store can
|
||||||
* parse large feeds in bounded chunks (yielding to the event loop between
|
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||||
* chunks) instead of one synchronous block. */
|
* chunks) instead of one synchronous block. */
|
||||||
@@ -158,6 +168,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
feedUrl,
|
feedUrl,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
episodes,
|
episodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
import type { Episode, Progress } from "../types/episode";
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -315,13 +316,18 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// ~300ms typically) — past the cap, play bare and let the fetch warm
|
||||||
// the cache for next time.
|
// the cache for next time.
|
||||||
const coverUrl = feed?.podcast.coverUrl;
|
|
||||||
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
if (coverUrl && !coverArtPath) {
|
if (coverUrl && !coverArtPath) {
|
||||||
const path = await Promise.race([
|
const path = await Promise.race([
|
||||||
@@ -338,7 +344,7 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
startPos = savedProgress.position;
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
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
|
// 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
|
// `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.
|
// — 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) {
|
if (episode.audioUrl && backend) {
|
||||||
// The preload must carry the cover AT LOAD: cover-art-files only
|
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||||
// applies when the file loads, and the runtime video-add fallback
|
// applies when the file loads, and the runtime video-add fallback
|
||||||
// never becomes an albumart track (verified). Restore already waits
|
// never becomes an albumart track (verified). Restore already waits
|
||||||
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||||
// 8s worst case) is free.
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
const coverUrl = feed?.podcast.coverUrl;
|
// the feed has no channel cover.
|
||||||
|
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
|
||||||
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
|
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
|
||||||
const backendSnap = backend;
|
const backendSnap = backend;
|
||||||
backendSnap
|
backendSnap
|
||||||
.preload(episode.audioUrl, {
|
.preload(url, {
|
||||||
volume: volume(),
|
volume: volume(),
|
||||||
speed: storeSpeed || speed(),
|
speed: storeSpeed || speed(),
|
||||||
startPosition: pos > 0 ? pos : undefined,
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
@@ -603,9 +612,11 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
.feeds()
|
.feeds()
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
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;
|
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
await backend.play(ep.audioUrl, {
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
|
|||||||
@@ -186,9 +186,13 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
|||||||
const backend = createAudioBackend()
|
const backend = createAudioBackend()
|
||||||
if (episodeResult.audioUrl) {
|
if (episodeResult.audioUrl) {
|
||||||
// Stage the podcast cover so the system Now Playing shows
|
// Stage the podcast cover so the system Now Playing shows
|
||||||
// artwork (mpv --cover-art-files), like the UI path does.
|
// artwork (mpv --cover-art-files), like the UI path does. Falls
|
||||||
const coverArtPath = feedResult.podcast.coverUrl
|
// back to the episode's own image when the feed has no channel
|
||||||
? await fetchCoverArt(feedResult.podcast.coverUrl)
|
// cover (URL-added feeds).
|
||||||
|
const coverUrl =
|
||||||
|
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||||
|
const coverArtPath = coverUrl
|
||||||
|
? await fetchCoverArt(coverUrl)
|
||||||
: null
|
: null
|
||||||
await backend.play(episodeResult.audioUrl, {
|
await backend.play(episodeResult.audioUrl, {
|
||||||
mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`,
|
mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { Podcast } from "../types/podcast";
|
|||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } 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 { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
import { mergeEpisodes } from "../utils/episode-merge";
|
import { mergeEpisodes } from "../utils/episode-merge";
|
||||||
@@ -324,14 +324,16 @@ function createFeedStore() {
|
|||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
/** 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
|
* Returns NULL when the feed could not be fetched (network error, non-OK
|
||||||
* response, timeout) — callers must treat null as "unchanged" and keep
|
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
||||||
* the previously loaded episodes. A failed refresh must never look like
|
* Also returns the channel-level artwork so callers can backfill a feed's
|
||||||
* an empty feed, or the store would wipe a subscribed show's episodes. */
|
* 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 (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<Episode[] | null> => {
|
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -342,8 +344,9 @@ function createFeedStore() {
|
|||||||
// refresh loop) indefinitely.
|
// refresh loop) indefinitely.
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
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 xml = await response.text();
|
||||||
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml;
|
||||||
const allEpisodes = sortEpisodesReverseChronological(
|
const allEpisodes = sortEpisodesReverseChronological(
|
||||||
await parseEpisodesIncremental(xml, feedUrl),
|
await parseEpisodesIncremental(xml, feedUrl),
|
||||||
);
|
);
|
||||||
@@ -354,9 +357,12 @@ function createFeedStore() {
|
|||||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allEpisodes.slice(0, limit);
|
return {
|
||||||
|
episodes: allEpisodes.slice(0, limit),
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { episodes: null, coverUrl: undefined };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,11 +399,14 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const feedId = crypto.randomUUID();
|
const feedId = crypto.randomUUID();
|
||||||
const episodes = await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
podcast.feedUrl,
|
podcast.feedUrl,
|
||||||
MAX_EPISODES_SUBSCRIBE,
|
MAX_EPISODES_SUBSCRIBE,
|
||||||
feedId,
|
feedId,
|
||||||
);
|
);
|
||||||
|
if (!podcast.coverUrl && coverUrl) {
|
||||||
|
podcast = { ...podcast, coverUrl };
|
||||||
|
}
|
||||||
const newFeed: Feed = {
|
const newFeed: Feed = {
|
||||||
id: feedId,
|
id: feedId,
|
||||||
podcast,
|
podcast,
|
||||||
@@ -482,7 +491,7 @@ function createFeedStore() {
|
|||||||
return activity.track((async () => {
|
return activity.track((async () => {
|
||||||
const feed = getFeed(feedId);
|
const feed = getFeed(feedId);
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
const episodes = await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feed.podcast.feedUrl,
|
feed.podcast.feedUrl,
|
||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
feedId,
|
feedId,
|
||||||
@@ -490,7 +499,14 @@ function createFeedStore() {
|
|||||||
// Fetch failed (null): keep the currently loaded episodes untouched.
|
// Fetch failed (null): keep the currently loaded episodes untouched.
|
||||||
if (!episodes) return;
|
if (!episodes) return;
|
||||||
setFeeds((prev) => {
|
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();
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
@@ -515,7 +531,7 @@ function createFeedStore() {
|
|||||||
feeds(),
|
feeds(),
|
||||||
FETCH_CONCURRENCY,
|
FETCH_CONCURRENCY,
|
||||||
async (feed) => {
|
async (feed) => {
|
||||||
const episodes = await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feed.podcast.feedUrl,
|
feed.podcast.feedUrl,
|
||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
feed.id,
|
feed.id,
|
||||||
@@ -523,7 +539,14 @@ function createFeedStore() {
|
|||||||
// A failed fetch (null) leaves that feed untouched.
|
// A failed fetch (null) leaves that feed untouched.
|
||||||
if (!episodes) return;
|
if (!episodes) return;
|
||||||
setFeeds((prev) => {
|
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();
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user