From 77bce2ae0c11f0cd374b9f611a678f5a03aaad4d Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 11:47:22 -0400 Subject: [PATCH] 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 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- server.js | 2 + server/feed-release.js | 84 +++++++++++++++++++++++++++++++ server/helpers.js | 1 + server/routes/admin-content.js | 5 +- server/routes/episodes.js | 2 +- server/routes/podcast-workflow.js | 17 +++---- server/routes/public.js | 4 +- src/AdminPage.tsx | 20 +++++++- src/content.ts | 3 ++ 11 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 server/feed-release.js diff --git a/package-lock.json b/package-lock.json index a88df13..eb3fdf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.18.0", + "version": "2.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.18.0", + "version": "2.19.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 0dd6462..40a6b10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.18.0", + "version": "2.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index eee87a8..b045d27 100644 --- a/server.js +++ b/server.js @@ -67,6 +67,7 @@ import { register as registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js' import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js' import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js' +import { warmFeedSnapshot } from './server/feed-release.js' import { preferIpv4ForSeafile } from './server/seafile-dns.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { register as registerFeed } from './server/routes/feed.js' @@ -208,6 +209,7 @@ Promise.all([ }, BACKUP_INTERVAL_MS) startNuggetsSeafileWatcher() + warmFeedSnapshot() // Purge expired study sessions every hour setInterval(() => { diff --git a/server/feed-release.js b/server/feed-release.js new file mode 100644 index 0000000..78e5598 --- /dev/null +++ b/server/feed-release.js @@ -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 } +} diff --git a/server/helpers.js b/server/helpers.js index abab79f..0b66321 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -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) : '', diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index 614aa5f..5d7f91e 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -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, diff --git a/server/routes/episodes.js b/server/routes/episodes.js index 14d4fad..a01c597 100644 --- a/server/routes/episodes.js +++ b/server/routes/episodes.js @@ -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() diff --git a/server/routes/podcast-workflow.js b/server/routes/podcast-workflow.js index dc48bfd..ec703c0 100644 --- a/server/routes/podcast-workflow.js +++ b/server/routes/podcast-workflow.js @@ -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 ?? {} diff --git a/server/routes/public.js b/server/routes/public.js index 73e980e..4ea8129 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -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 : []), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 835a269..f15ac3f 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -2762,6 +2762,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) { id: Date.now().toString(36), lane: 'primary' as const, + holdUntilReleased: true, title: '', episodeNumber: '', summary: '', @@ -2778,7 +2779,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) })) } - function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) { + function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string | boolean) { setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link), @@ -5151,6 +5152,22 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book's Finished Books page. General highlights stay.

+ {activeHighlight.lane !== 'general' && ( +
+ +

+ Hidden on the site until this lane's season number and the episode number below appear in your Spotify feed (checked every few minutes). Needs an episode number. Set each lane's season number on the Episode Workflow page. +

+
+ )}
updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} /> @@ -6185,6 +6202,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} +

Leave this as "No audio" to fill it in automatically: once this lane's season number and the Episode # below appear in your Spotify feed, the lesson uses that episode's audio.

diff --git a/src/content.ts b/src/content.ts index 55e410f..4b0cf61 100644 --- a/src/content.ts +++ b/src/content.ts @@ -71,6 +71,9 @@ export interface PodcastFeaturedLink { // Which series this highlight belongs to: Lane A (primary), Lane B (second), or general. // Missing = primary (older highlights were all about the one running series). lane?: 'primary' | 'second' | 'general' + // When true the highlight stays hidden on the public site until its season + episode number + // appear in the Spotify feed (the lane's season number is set on the Episode Workflow page). + holdUntilReleased?: boolean title: string episodeNumber?: string summary: string