2eb98b66d0
- New FinishedBook type in content.ts + finishedBooks[] field on SiteContent - RSS parser now extracts itunes:season into each episode object - /finished grid page and /finished/:id playlist page (filters episodes by season) - Episodes nav link replaced with dropdown: Current Series / Finished Books - Finished Books link added to footer - Admin: "End Current Series & Start New Book" wizard on Current Series tab - Admin: Finished Books management tab (add/edit/remove entries) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
7.2 KiB
JavaScript
190 lines
7.2 KiB
JavaScript
import { sanitizeUrl } from '../study-helpers.js'
|
|
|
|
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
|
let episodesCache = null
|
|
let episodesCacheAt = 0
|
|
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
|
|
}
|
|
|
|
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()
|
|
const episodes = parseRssItems(xml)
|
|
episodesCache = episodes
|
|
episodesCacheAt = now
|
|
return episodes
|
|
}
|
|
|
|
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 })) })
|
|
}
|
|
})
|
|
}
|