Hold episode highlights until the episode is in the Spotify feed; auto-fill study lesson audio from the feed by season + episode; v2.19.0
Publish Container / build (push) Successful in 16m16s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 11:47:22 -04:00
1 parent 69d445013a
commit 77bce2ae0c
11 files changed
+127 -17

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import { fetchAllEpisodes } from './routes/episodes.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed
// - study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
let snapshot = { items: [], at: 0 }
let inflight = null
function refreshSnapshot() {
if (!inflight) {
inflight = fetchAllEpisodes()
.then(items => { snapshot = { items, at: Date.now() } })
// Keep serving the last good copy; retry on the next TTL window
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
.finally(() => { inflight = null })
}
return inflight
}
async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
const index = new Map()
for (const item of snapshot.items) {
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
}
return index
}
export function warmFeedSnapshot() {
return refreshSnapshot()
}
// Returns a copy of the published site content with the feed applied. Run it before
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
export async function applyFeedRelease(content) {
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : []
if (!links.some(l => l?.holdUntilReleased) && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content)
const seasonB = laneSeasonOf('B', wf, content)
const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against
if (!link?.holdUntilReleased || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null
if (!item) return []
return [{ ...link, url: link.url || item.link || '' }]
})
const studyLane = study => {
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
return ''
}
const nextStudies = studies.map(study => {
const lane = studyLane(study)
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
if (!season || !Array.isArray(study.sections)) return study
return {
...study,
sections: study.sections.map(section => {
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
}),
}
})
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
}
+1
View File
@@ -142,6 +142,7 @@ export function sanitizeFeaturedLinks(value) {
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
holdUntilReleased: item.holdUntilReleased === true,
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
+3 -2
View File
@@ -1,3 +1,4 @@
import { applyFeedRelease } from '../feed-release.js'
import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js'
@@ -249,7 +250,7 @@ export function register(app) {
try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
res.json({ ...parsed, siteContent: safeSiteContent })
return
}
@@ -279,7 +280,7 @@ export function register(app) {
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {}
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
res.json({
seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL,
+1 -1
View File
@@ -5,7 +5,7 @@ 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
const EPISODES_CACHE_TTL = 10 * 60 * 1000
export async function fetchAllEpisodes() {
const now = Date.now()
+8 -9
View File
@@ -11,7 +11,7 @@ const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
const STEPS = ['readScript', 'record', 'edit', 'upload']
async function load() {
export async function loadPodcastWorkflow() {
try {
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
@@ -30,10 +30,9 @@ function emptyState() {
}
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
function laneSeasonOf(lane, wf) {
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
const saved = wf.laneSeasons?.[lane]
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
const content = state.cachedSiteContent ?? {}
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
return fallback ? String(fallback) : ''
}
@@ -55,7 +54,7 @@ function toIsoDate(pubDate) {
export function register(app) {
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
const wf = await load()
const wf = await loadPodcastWorkflow()
let feed = []
let feedOk = true
try {
@@ -119,7 +118,7 @@ export function register(app) {
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
return
}
const wf = await load()
const wf = await loadPodcastWorkflow()
const key = scriptKey(season, episodeNumber)
if (wf.planned.some(p => plannedKey(p) === key)) {
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
@@ -131,7 +130,7 @@ export function register(app) {
})
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
const wf = await load()
const wf = await loadPodcastWorkflow()
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
delete wf.items[req.params.key]
delete wf.laneOverrides[req.params.key]
@@ -141,7 +140,7 @@ export function register(app) {
// Set the season number each lane looks for ('' goes back to the Current Series value).
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
const wf = await load()
const wf = await loadPodcastWorkflow()
for (const lane of ['A', 'B']) {
const v = req.body?.[lane]
if (v === undefined) continue
@@ -161,7 +160,7 @@ export function register(app) {
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
return
}
const wf = await load()
const wf = await loadPodcastWorkflow()
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
else wf.laneOverrides[req.params.key] = lane
await save(wf)
@@ -170,7 +169,7 @@ export function register(app) {
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
const { key } = req.params
const wf = await load()
const wf = await loadPodcastWorkflow()
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
const next = { ...prev, steps: { ...prev.steps } }
const body = req.body ?? {}
+3 -1
View File
@@ -1,4 +1,5 @@
import express from 'express'
import { applyFeedRelease } from '../feed-release.js'
import path from 'node:path'
import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
@@ -59,6 +60,7 @@ export function register(app) {
content = parsed.siteContent
} catch { content = {} }
}
content = await applyFeedRelease(content)
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
@@ -196,7 +198,7 @@ export function register(app) {
// Scripture index — every passage covered across released study lessons and podcast episodes
app.get('/api/public/scripture-index', async (_req, res) => {
const content = state.cachedSiteContent
const content = await applyFeedRelease(state.cachedSiteContent)
const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = [
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),