Add Verse By Verse: Nuggets sub-show with editable hosts and RSS feed
Publish Container / docker (push) Successful in 14m16s
Publish Container / docker (push) Successful in 14m16s
New /nuggets page for the companion show, linked from the Episodes nav dropdown. Hosts (name, role, bio, photo) show in a left column with the episode list on the right — latest episode featured, rest below. Episodes are pulled from an admin-configurable RSS feed URL (empty by default, shows "coming soon" until set) rather than hardcoded like the main show's feed, since the show doesn't have one yet. Everything is editable in /admin under a new Nuggets section: Show Settings (title, tagline, description, podcast art, RSS/platform URLs) and Hosts (add freely; removing a host archives them instead of deleting — kept on record and restorable, never lost). Extracted the RSS item-parsing logic shared with the main show's feed (server/routes/episodes.js) into server/rss-helpers.js so both feeds use the same parser instead of duplicating it. Also fixes the local Vite dev server not proxying /uploads/* to the backend — it was silently serving the SPA shell (200, text/html) instead of the actual file, so any admin-uploaded image (podcast art, host photos, etc.) appeared broken in local dev only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
1 parent
335a9cbe31
commit
31e79ad542
11 files changed
+573
-41
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { sanitizeUrl } from '../study-helpers.js'
|
||||
import { parseRssItems } from '../rss-helpers.js'
|
||||
|
||||
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
||||
let episodesCache = null
|
||||
@@ -6,41 +7,6 @@ let episodesCacheAt = 0
|
||||
let rawXmlCache = null
|
||||
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
||||
|
||||
function extractCdata(raw) {
|
||||
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
|
||||
return cdata ? cdata[1].trim() : raw.trim()
|
||||
}
|
||||
|
||||
function parseRssItems(xml, limit = Infinity) {
|
||||
const items = []
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||||
let match
|
||||
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
|
||||
const block = match[1]
|
||||
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
|
||||
const title = extractCdata(titleRaw)
|
||||
if (!title) continue
|
||||
|
||||
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
|
||||
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
|
||||
const guid = extractCdata(guidRaw)
|
||||
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
|
||||
const link = guid.startsWith('http') ? guid : enclosureUrl
|
||||
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
|
||||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||||
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
|
||||
items.push({
|
||||
title, pubDate, link,
|
||||
audioUrl: enclosureUrl,
|
||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||||
duration, episode, season,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function fetchAllEpisodes() {
|
||||
const now = Date.now()
|
||||
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { parseRssItems } from '../rss-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
|
||||
let cache = null // { url, episodes }
|
||||
let cacheAt = 0
|
||||
const CACHE_TTL = 30 * 60 * 1000
|
||||
|
||||
function feedUrl() {
|
||||
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
|
||||
}
|
||||
|
||||
async function fetchNuggetsEpisodes() {
|
||||
const url = feedUrl()
|
||||
if (!url) { cache = null; return [] }
|
||||
|
||||
const now = Date.now()
|
||||
if (cache && cache.url === url && (now - cacheAt) < CACHE_TTL) {
|
||||
return cache.episodes
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
|
||||
const xml = await response.text()
|
||||
const episodes = parseRssItems(xml)
|
||||
cache = { url, episodes }
|
||||
cacheAt = now
|
||||
return episodes
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
// Serve stale cache for this same URL rather than a hard failure, if we have one.
|
||||
if (cache && cache.url === url) return cache.episodes
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes: episodes.slice(0, 6), configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/nuggets/episodes/all', async (_req, res) => {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes, configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Shared podcast-RSS parsing, used by both the main show (episodes.js) and
|
||||
// any sub-show fed by its own feed URL (nuggets.js).
|
||||
|
||||
export function extractCdata(raw) {
|
||||
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
|
||||
return cdata ? cdata[1].trim() : raw.trim()
|
||||
}
|
||||
|
||||
export function parseRssItems(xml, limit = Infinity) {
|
||||
const items = []
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||||
let match
|
||||
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
|
||||
const block = match[1]
|
||||
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
|
||||
const title = extractCdata(titleRaw)
|
||||
if (!title) continue
|
||||
|
||||
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
|
||||
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
|
||||
const guid = extractCdata(guidRaw)
|
||||
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
|
||||
const link = guid.startsWith('http') ? guid : enclosureUrl
|
||||
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
|
||||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||||
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
|
||||
items.push({
|
||||
title, pubDate, link,
|
||||
audioUrl: enclosureUrl,
|
||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||||
duration, episode, season,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
Reference in new issue
Block a user