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
163 lines
5.8 KiB
JavaScript
163 lines
5.8 KiB
JavaScript
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
|
|
let episodesCacheAt = 0
|
|
let rawXmlCache = null
|
|
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
|
|
|
export async function fetchAllEpisodes() {
|
|
const now = Date.now()
|
|
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
|
|
return episodesCache
|
|
}
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 8000)
|
|
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
|
|
clearTimeout(timeout)
|
|
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
|
|
const xml = await response.text()
|
|
rawXmlCache = xml
|
|
const episodes = parseRssItems(xml)
|
|
episodesCache = episodes
|
|
episodesCacheAt = now
|
|
return episodes
|
|
}
|
|
|
|
export async function fetchRawFeedXml() {
|
|
await fetchAllEpisodes()
|
|
return rawXmlCache
|
|
}
|
|
|
|
function toSpotifyEpisodeEmbedUrl(urlValue) {
|
|
if (!urlValue) return ''
|
|
try {
|
|
const parsed = new URL(urlValue)
|
|
if (parsed.protocol !== 'https:') return ''
|
|
const host = parsed.hostname.toLowerCase()
|
|
const parts = parsed.pathname.split('/').filter(Boolean)
|
|
if (host === 'open.spotify.com') {
|
|
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
|
|
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
|
|
}
|
|
if (parts[0] === 'episode' && parts[1]) {
|
|
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
|
|
}
|
|
}
|
|
} catch { return '' }
|
|
return ''
|
|
}
|
|
|
|
function decodeEscapedJsonUrl(value) {
|
|
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
|
|
}
|
|
|
|
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
|
|
const input = String(html || '')
|
|
if (!input) return ''
|
|
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
|
|
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
|
|
let match
|
|
let firstEpisodeId = ''
|
|
while ((match = blockRegex.exec(input)) !== null) {
|
|
const episodeSlug = match[1]
|
|
const spotifyUrl = decodeEscapedJsonUrl(match[2])
|
|
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
|
|
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
|
|
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) return episodeId
|
|
}
|
|
if (firstEpisodeId) return firstEpisodeId
|
|
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
|
|
return urlMatch ? (urlMatch[2] || '') : ''
|
|
}
|
|
|
|
function isAllowedSpotifyResolverHost(hostname) {
|
|
const host = String(hostname || '').toLowerCase()
|
|
return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com'
|
|
}
|
|
|
|
export function register(app) {
|
|
app.get('/api/spotify/embed-url', async (req, res) => {
|
|
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
|
|
const safeInput = sanitizeUrl(incoming)
|
|
|
|
if (!safeInput || safeInput.startsWith('/')) {
|
|
res.status(400).json({ message: 'A valid episode URL is required.' }); return
|
|
}
|
|
|
|
let parsed
|
|
try {
|
|
parsed = new URL(safeInput)
|
|
} catch {
|
|
res.status(400).json({ message: 'Malformed URL.' }); return
|
|
}
|
|
|
|
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
|
|
res.status(400).json({ message: 'Unsupported episode URL host.' }); return
|
|
}
|
|
|
|
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
|
|
if (directEmbed) {
|
|
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }); return
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 8000)
|
|
const response = await fetch(safeInput, {
|
|
signal: controller.signal,
|
|
headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html' },
|
|
})
|
|
clearTimeout(timeout)
|
|
|
|
if (!response.ok) {
|
|
res.status(404).json({ message: 'Could not fetch episode page.' }); return
|
|
}
|
|
|
|
const html = await response.text()
|
|
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
|
|
|
|
if (!spotifyEpisodeId) {
|
|
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }); return
|
|
}
|
|
|
|
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
|
|
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
|
|
} catch (err) {
|
|
console.error('[spotify/embed-url] resolve error:', err.message)
|
|
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/episodes', async (_req, res) => {
|
|
try {
|
|
const episodes = await fetchAllEpisodes()
|
|
res.json({ episodes: episodes.slice(0, 6) })
|
|
} catch (err) {
|
|
console.error('[episodes] RSS fetch error:', err.message)
|
|
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
|
|
}
|
|
})
|
|
|
|
app.get('/api/episodes/all', async (_req, res) => {
|
|
try {
|
|
const episodes = await fetchAllEpisodes()
|
|
res.json({ episodes })
|
|
} catch (err) {
|
|
console.error('[episodes/all] RSS fetch error:', err.message)
|
|
res.json({ episodes: episodesCache ?? [] })
|
|
}
|
|
})
|
|
|
|
app.get('/api/episode-audio', async (_req, res) => {
|
|
try {
|
|
const episodes = await fetchAllEpisodes()
|
|
res.json({ episodes: episodes.map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
|
|
} catch (err) {
|
|
console.error('[episode-audio] RSS fetch error:', err.message)
|
|
res.json({ episodes: (episodesCache ?? []).map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
|
|
}
|
|
})
|
|
}
|