Add Episode Highlights player with Spotify embed auto-resolution and direct embed URL support

This commit is contained in:
nmemmert
2026-05-07 11:36:07 -04:00
parent 93548b1ac8
commit 7568be368e
5 changed files with 527 additions and 143 deletions
+95 -13
View File
@@ -2,6 +2,9 @@
"siteContent": { "siteContent": {
"eyebrow": "A Journey Through Scripture", "eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time", "heroTagline": "Exploring God's Word one verse at a time",
"heroBtnSpotify": "Listen on Spotify",
"heroBtnEpisodes": "Explore Episodes ↓",
"heroBtnStartHere": "New here? Start here →",
"startHereHeading": "New Here? Start Here", "startHereHeading": "New Here? Start Here",
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.", "startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes", "startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
@@ -13,6 +16,9 @@
"startHereStep3Title": "Step 3: Ask Nate Directly", "startHereStep3Title": "Step 3: Ask Nate Directly",
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.", "startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
"startHereStep3Cta": "Submit a Question", "startHereStep3Cta": "Submit a Question",
"aboutEyebrow": "About Nate",
"aboutListenBtnLabel": "Listen Now ↓",
"aboutShowEyebrow": "About the Show",
"aboutShowHeading": "Depth. Clarity. Application.", "aboutShowHeading": "Depth. Clarity. Application.",
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.", "aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.", "aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
@@ -20,17 +26,95 @@
"aboutPhotoUrl": "/images/nate-photo.jpeg", "aboutPhotoUrl": "/images/nate-photo.jpeg",
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png", "aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
"contactPhotoUrl": "/images/nate-contact-photo.png", "contactPhotoUrl": "/images/nate-contact-photo.png",
"contactEyebrow": "Get in Touch",
"contactHeading": "Contact Nate",
"contactName": "Nate",
"contactRole": "Bible teacher · Lynchburg, VA",
"contactQuote": "I read every message - nothing blesses me more than hearing how God's Word is at work in your life.",
"contactIntro": "Ask a Bible question, share a testimony, or request a topic for a future episode.",
"contactPoint1": "I read every message personally",
"contactPoint2": "Response within 48 hours",
"contactVerse": "Your word is a lamp to my feet and a light to my path.",
"contactVerseRef": "Psalm 119:105",
"seriesLabel": "Now Playing", "seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine", "seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.", "seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png", "seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl", "seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"studyGuideTitle": "Companion Study Guide", "studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "", "studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ", "studyGuideUrl": "https://a.co/d/01sG2tOJ",
"studyGuideAmazonButtonLabel": "Get it on Amazon",
"shareHeading": "Help one more person hear the Word this week.", "shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"whereToNextEyebrow": "Where to Next",
"whereToNextHeading": "Choose the page you need.",
"whereToNextCards": [
{
"id": "episodes",
"title": "Episodes",
"description": "Listen to latest episodes and platform links.",
"path": "/episodes"
},
{
"id": "downloads",
"title": "Downloads",
"description": "Main guide, extra downloads, and past study files.",
"path": "/resources"
},
{
"id": "qa",
"title": "Q&A",
"description": "Browse Bible questions and approved answers.",
"path": "/questions"
},
{
"id": "about",
"title": "About",
"description": "Learn about Nate and the mission of the show.",
"path": "/about"
},
{
"id": "contact",
"title": "Contact",
"description": "Submit a Bible question, testimony, or topic.",
"path": "/contact"
}
],
"footerTitle": "Verse by Verse with Nate",
"footerSubtitle": "A Journey Through Scripture",
"footerEmail": "hello@versebyversewithnate.us",
"footerCopyright": "© 2026 Nate Emmert · Made with faith.",
"footerPrivacyNote": "Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.",
"platformSpotifyUrl": "/spotify",
"platformAppleUrl": "/apple",
"platformYoutubeUrl": "https://www.youtube.com/@blackzebraem5558",
"platformAmazonUrl": "/amazon",
"platformFacebookUrl": "https://facebook.com/versebyversewithnate",
"platformCreatorUrl": "https://creators.spotify.com/pod/profile/nmemmert/",
"welcomeEmailSubject": "Welcome to Verse by Verse with Nate",
"welcomeEmailGreetingPrefix": "Glad you're here",
"welcomeEmailIntro": "Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.",
"welcomeEmailCurrentSeries": "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
"welcomeEmailStartHereTitle": "Episode 1 - Introduction to Titus",
"welcomeEmailStartHereSummary": "Who wrote it, who received it, and why it still matters.",
"welcomeEmailStartHereUrl": "/start-here",
"welcomeEmailSpotifyUrl": "/spotify",
"welcomeEmailAppleUrl": "/apple",
"welcomeEmailAmazonUrl": "/amazon",
"welcomeEmailWebsiteUrl": "https://versebyversewithnate.us",
"welcomeEmailImageUrl": "/images/podcast-art.jpeg",
"welcomeEmailWhatToExpect1": "Verse-by-verse teaching - we go slow and let the text speak for itself.",
"welcomeEmailWhatToExpect2": "Greek word studies - the kind that open up meaning without being a lecture.",
"welcomeEmailWhatToExpect3": "New episodes + study notes delivered right to your inbox.",
"welcomeEmailScripture": "For the grace of God has appeared, bringing salvation to all people.",
"welcomeEmailScriptureRef": "Titus 2:11 - BSB",
"welcomeEmailSignoff": "Grace and peace,\nNate",
"headerFollowLabel": "Follow on Spotify",
"cookieBannerText": "We use optional analytics cookies to measure visits and location trends for site improvement.",
"customLinks": [], "customLinks": [],
"customBlocks": [], "customBlocks": [],
"archivedSeries": [], "archivedSeries": [],
@@ -56,17 +140,15 @@
], ],
"podcastFeaturedLinks": [ "podcastFeaturedLinks": [
{ {
"id": "sample-highlight-1", "id": "movn6tk3",
"title": "Introduction to Titus", "title": "news",
"episodeNumber": "2", "episodeNumber": "1",
"summary": "A practical walkthrough of context, structure, and application for this study.", "summary": "news",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw", "url": "",
"embedUrl": "", "embedUrl": "https://open.spotify.com/embed/episode/4fDzD2q9XtVtOsOq0xlgLn?utm_source=generator",
"showNotes": "", "showNotes": "test",
"discussionQuestions": [ "discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?", "tes"
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
] ]
} }
], ],
@@ -101,5 +183,5 @@
] ]
} }
}, },
"updatedAt": "2026-05-05T20:02:33.053Z" "updatedAt": "2026-05-07T15:35:23.888Z"
} }
+89 -29
View File
@@ -2,6 +2,9 @@
"siteContent": { "siteContent": {
"eyebrow": "A Journey Through Scripture", "eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time", "heroTagline": "Exploring God's Word one verse at a time",
"heroBtnSpotify": "Listen on Spotify",
"heroBtnEpisodes": "Explore Episodes ↓",
"heroBtnStartHere": "New here? Start here →",
"startHereHeading": "New Here? Start Here", "startHereHeading": "New Here? Start Here",
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.", "startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes", "startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
@@ -13,6 +16,9 @@
"startHereStep3Title": "Step 3: Ask Nate Directly", "startHereStep3Title": "Step 3: Ask Nate Directly",
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.", "startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
"startHereStep3Cta": "Submit a Question", "startHereStep3Cta": "Submit a Question",
"aboutEyebrow": "About Nate",
"aboutListenBtnLabel": "Listen Now ↓",
"aboutShowEyebrow": "About the Show",
"aboutShowHeading": "Depth. Clarity. Application.", "aboutShowHeading": "Depth. Clarity. Application.",
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.", "aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.", "aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
@@ -20,40 +26,96 @@
"aboutPhotoUrl": "/images/nate-photo.jpeg", "aboutPhotoUrl": "/images/nate-photo.jpeg",
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png", "aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
"contactPhotoUrl": "/images/nate-contact-photo.png", "contactPhotoUrl": "/images/nate-contact-photo.png",
"contactEyebrow": "Get in Touch",
"contactHeading": "Contact Nate",
"contactName": "Nate",
"contactRole": "Bible teacher · Lynchburg, VA",
"contactQuote": "I read every message - nothing blesses me more than hearing how God's Word is at work in your life.",
"contactIntro": "Ask a Bible question, share a testimony, or request a topic for a future episode.",
"contactPoint1": "I read every message personally",
"contactPoint2": "Response within 48 hours",
"contactVerse": "Your word is a lamp to my feet and a light to my path.",
"contactVerseRef": "Psalm 119:105",
"seriesLabel": "Now Playing", "seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine", "seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.", "seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png", "seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl", "seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"studyGuideTitle": "Companion Study Guide", "studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "", "studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ", "studyGuideUrl": "https://a.co/d/01sG2tOJ",
"studyGuideAmazonButtonLabel": "Get it on Amazon",
"shareHeading": "Help one more person hear the Word this week.", "shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"customLinks": [ "whereToNextEyebrow": "Where to Next",
"whereToNextHeading": "Choose the page you need.",
"whereToNextCards": [
{ {
"id": "pocket-casts", "id": "episodes",
"label": "Pocket Casts", "title": "Episodes",
"url": "https://pca.st/76jisz3d", "description": "Listen to latest episodes and platform links.",
"placement": "platforms", "path": "/episodes"
"imageUrl": "/images/listen-on-pocket-casts.svg"
}, },
{ {
"id": "castbox", "id": "downloads",
"label": "Castbox", "title": "Downloads",
"url": "https://castbox.fm/channel/id7140701?country=us", "description": "Main guide, extra downloads, and past study files.",
"placement": "platforms", "path": "/resources"
"imageUrl": "/images/listen-on-castbox.svg"
}, },
{ {
"id": "goodpods", "id": "qa",
"label": "Goodpods", "title": "Q&A",
"url": "https://goodpods.com/podcasts/verse-by-verse-with-nate-732127", "description": "Browse Bible questions and approved answers.",
"placement": "platforms", "path": "/questions"
"imageUrl": "https://storage.googleapis.com/goodpods-images-bucket/badges/generic-badge-3.svg" },
{
"id": "about",
"title": "About",
"description": "Learn about Nate and the mission of the show.",
"path": "/about"
},
{
"id": "contact",
"title": "Contact",
"description": "Submit a Bible question, testimony, or topic.",
"path": "/contact"
} }
], ],
"footerTitle": "Verse by Verse with Nate",
"footerSubtitle": "A Journey Through Scripture",
"footerEmail": "hello@versebyversewithnate.us",
"footerCopyright": "© 2026 Nate Emmert · Made with faith.",
"footerPrivacyNote": "Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.",
"platformSpotifyUrl": "/spotify",
"platformAppleUrl": "/apple",
"platformYoutubeUrl": "https://www.youtube.com/@blackzebraem5558",
"platformAmazonUrl": "/amazon",
"platformFacebookUrl": "https://facebook.com/versebyversewithnate",
"platformCreatorUrl": "https://creators.spotify.com/pod/profile/nmemmert/",
"welcomeEmailSubject": "Welcome to Verse by Verse with Nate",
"welcomeEmailGreetingPrefix": "Glad you're here",
"welcomeEmailIntro": "Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.",
"welcomeEmailCurrentSeries": "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
"welcomeEmailStartHereTitle": "Episode 1 - Introduction to Titus",
"welcomeEmailStartHereSummary": "Who wrote it, who received it, and why it still matters.",
"welcomeEmailStartHereUrl": "/start-here",
"welcomeEmailSpotifyUrl": "/spotify",
"welcomeEmailAppleUrl": "/apple",
"welcomeEmailAmazonUrl": "/amazon",
"welcomeEmailWebsiteUrl": "https://versebyversewithnate.us",
"welcomeEmailImageUrl": "/images/podcast-art.jpeg",
"welcomeEmailWhatToExpect1": "Verse-by-verse teaching - we go slow and let the text speak for itself.",
"welcomeEmailWhatToExpect2": "Greek word studies - the kind that open up meaning without being a lecture.",
"welcomeEmailWhatToExpect3": "New episodes + study notes delivered right to your inbox.",
"welcomeEmailScripture": "For the grace of God has appeared, bringing salvation to all people.",
"welcomeEmailScriptureRef": "Titus 2:11 - BSB",
"welcomeEmailSignoff": "Grace and peace,\nNate",
"headerFollowLabel": "Follow on Spotify",
"cookieBannerText": "We use optional analytics cookies to measure visits and location trends for site improvement.",
"customLinks": [],
"customBlocks": [], "customBlocks": [],
"archivedSeries": [], "archivedSeries": [],
"redirects": [ "redirects": [
@@ -78,17 +140,15 @@
], ],
"podcastFeaturedLinks": [ "podcastFeaturedLinks": [
{ {
"id": "sample-highlight-1", "id": "movn6tk3",
"title": "Introduction to Titus", "title": "news",
"episodeNumber": "2", "episodeNumber": "1",
"summary": "A practical walkthrough of context, structure, and application for this study.", "summary": "news",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw", "url": "",
"embedUrl": "", "embedUrl": "https://open.spotify.com/embed/episode/4fDzD2q9XtVtOsOq0xlgLn?utm_source=generator",
"showNotes": "", "showNotes": "test",
"discussionQuestions": [ "discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?", "tes"
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
] ]
} }
], ],
@@ -123,5 +183,5 @@
] ]
} }
}, },
"updatedAt": "2026-05-04T17:42:41.949Z" "updatedAt": "2026-05-07T15:35:23.896Z"
} }
+7 -3
View File
@@ -1,11 +1,13 @@
[ {
"questions": [
{ {
"id": "cb_001", "id": "cb_001",
"firstName": "Nate", "firstName": "Nate",
"question": "What Bible translation do you use?", "question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.", "answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true, "isApproved": true,
"topic": "Bible Study" "topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
}, },
{ {
"id": "cb_002", "id": "cb_002",
@@ -95,4 +97,6 @@
"isApproved": true, "isApproved": true,
"topic": "Faith & Life" "topic": "Faith & Life"
} }
] ],
"updatedAt": "2026-05-07T15:35:23.896Z"
}
+124
View File
@@ -2702,6 +2702,130 @@ async function fetchAllEpisodes() {
return episodes 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 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'
}
function decodeEscapedJsonUrl(value) {
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
}
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) => { app.get('/api/episodes', async (_req, res) => {
try { try {
const episodes = await fetchAllEpisodes() const episodes = await fetchAllEpisodes()
+117 -3
View File
@@ -13,6 +13,48 @@ const SPOTIFY_EMBED_URL =
const CONSENT_KEY = 'vbn_analytics_consent_choice' const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj' const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
if (!url) return ''
try {
const parsed = new URL(url)
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 isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
if (!url) return false
if (toSpotifyEpisodeEmbedUrl(url)) return true
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const path = parsed.pathname.toLowerCase()
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
} catch {
return false
}
return false
}
function HeadlinerWidget() { function HeadlinerWidget() {
const [iframeSrc, setIframeSrc] = useState('') const [iframeSrc, setIframeSrc] = useState('')
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading') const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
@@ -683,7 +725,12 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
</h2> </h2>
<div className="resources-list"> <div className="resources-list">
{links.map(link => { {links.map(link => {
const hasRichContent = !!(link.embedUrl || (link.discussionQuestions ?? []).length > 0 || link.showNotes) const hasRichContent = !!(
link.embedUrl
|| isLikelySpotifyEpisodeUrl(link.url)
|| (link.discussionQuestions ?? []).length > 0
|| link.showNotes
)
const label = <>{link.episodeNumber ? `Ep. ${link.episodeNumber}: ` : ''}{link.title || link.url}</> const label = <>{link.episodeNumber ? `Ep. ${link.episodeNumber}: ` : ''}{link.title || link.url}</>
// Use external link only when there's a url AND no rich detail-page content // Use external link only when there's a url AND no rich detail-page content
if (!hasRichContent && link.url) { if (!hasRichContent && link.url) {
@@ -1004,6 +1051,73 @@ function LandingPage({ content }: { content: SiteContent }) {
function EpisodeDetailPage({ content }: { content: SiteContent }) { function EpisodeDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const episode = (content.podcastFeaturedLinks ?? []).find(e => e.id === id) const episode = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
useEffect(() => {
let cancelled = false
if (!episode) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
const manualEmbed = episode.embedUrl?.trim() ?? ''
if (manualEmbed) {
setResolvedEmbedUrl(manualEmbed)
return () => {
cancelled = true
}
}
if (!episode.url) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
try {
const urlObj = new URL(episode.url)
const host = urlObj.hostname.toLowerCase()
if (host === 'open.spotify.com') {
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
if (fromUrl) {
setResolvedEmbedUrl(fromUrl)
return () => {
cancelled = true
}
}
}
} catch {
// ignore
}
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
setResolvedEmbedUrl('')
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
.then((data: { embedUrl?: string }) => {
if (cancelled) return
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
})
.catch(() => {
if (cancelled) return
setResolvedEmbedUrl('')
})
return () => {
cancelled = true
}
}, [episode])
if (!episode) { if (!episode) {
return ( return (
@@ -1037,10 +1151,10 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
<h1 className="episode-detail-title">{episode.title}</h1> <h1 className="episode-detail-title">{episode.title}</h1>
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>} {episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
{episode.embedUrl && ( {resolvedEmbedUrl && (
<div className="episode-detail-embed"> <div className="episode-detail-embed">
<iframe <iframe
src={episode.embedUrl} src={resolvedEmbedUrl}
title={episode.title} title={episode.title}
width="100%" width="100%"
height="152" height="152"