Episode scripts: file by season + episode so numbers can repeat per book; search labels for current series; v2.9.6
Publish Container / build (push) Successful in 16m51s

- Scripts keyed "<season>-<episode>" (legacy ones migrate on first start; unseasoned ones keep their plain key)
- Season backfill handles episode numbers shared across seasons (title match fallback)
- Fix: "Backfill seasons from RSS" was swallowed by the upload route
- Global search labels script results with the title of the series playing now (both lanes), not just finished books

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-08 15:31:11 -04:00
1 parent 4ddedf0f81
commit 752611d121
7 files changed
+114 -41

No files matched your search

+6 -1
View File
@@ -1,3 +1,4 @@
import { migrateScriptKeys } from './episode-script-keys.js'
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
@@ -804,7 +805,11 @@ export async function loadEpisodeScriptsFromDisk() {
try {
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
const loaded = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
const { scripts, changed } = migrateScriptKeys(loaded)
state.episodeScripts = scripts
// Persist the move to season-episode keys once, so it isn't redone on every start
if (changed) queueEpisodeScriptsWrite()
} catch {
state.episodeScripts = {}
}
+33
View File
@@ -0,0 +1,33 @@
// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible
// is its own podcast season and episode numbers can restart in every season. A script with no
// season keeps its plain episode number as its key.
export function scriptKey(season, episodeNumber) {
const ep = String(episodeNumber ?? '').trim()
const s = String(season ?? '').trim()
return /^\d+$/.test(s) ? `${s}-${ep}` : ep
}
// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it).
export function scriptEpisodeNumber(key, entry) {
if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber
const parts = String(key).split('-')
return parts[parts.length - 1]
}
// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }).
// Scripts without a season stay under their plain episode number. Never overwrites an existing key.
export function migrateScriptKeys(scripts) {
const next = {}
let changed = false
for (const [key, entry] of Object.entries(scripts ?? {})) {
if (!entry || typeof entry !== 'object') continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const wanted = scriptKey(entry.season, episodeNumber)
const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key
const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber }
if (targetKey !== key || withEpisode !== entry) changed = true
next[targetKey] = withEpisode
}
return { scripts: next, changed }
}
+58 -27
View File
@@ -4,6 +4,7 @@ import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
import { fetchAllEpisodes } from './episodes.js'
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -30,14 +31,18 @@ function searchScripts(query) {
const season = seasonQuery[1]
return Object.entries(state.episodeScripts)
.filter(([, entry]) => String(entry.season ?? '') === season)
.map(([episodeNumber, entry]) => ({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
snippet: '',
score: 1,
}))
.map(([key, entry]) => {
const episodeNumber = scriptEpisodeNumber(key, entry)
return {
key,
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
snippet: '',
score: 1,
}
})
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
.slice(0, 8)
}
@@ -46,8 +51,9 @@ function searchScripts(query) {
if (words.length === 0) return []
const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
for (const [key, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const text = entry.text.toLowerCase()
const matchedWords = words.filter(w => containsWord(text, w))
@@ -67,6 +73,7 @@ function searchScripts(query) {
if (end < entry.text.length) snippet = snippet + '…'
results.push({
key,
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
@@ -89,8 +96,9 @@ export function register(app) {
// Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
episodeNumber,
const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
key,
episodeNumber: scriptEpisodeNumber(key, entry),
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
@@ -102,7 +110,9 @@ export function register(app) {
})
// Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => {
// "backfill-seasons" is its own route below; don't let this one swallow it
if (req.params.episodeNumber === 'backfill-seasons') { next(); return }
const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
@@ -144,7 +154,13 @@ export function register(app) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
}
state.episodeScripts[episodeNumber] = {
const key = scriptKey(season, episodeNumber)
// A script with no season that this upload replaces would otherwise linger as a duplicate
if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) {
delete state.episodeScripts[episodeNumber]
}
state.episodeScripts[key] = {
episodeNumber,
title,
filename,
season,
@@ -156,9 +172,10 @@ export function register(app) {
res.json({
ok: true,
key,
episodeNumber,
season,
wordCount: state.episodeScripts[episodeNumber].wordCount,
wordCount: state.episodeScripts[key].wordCount,
})
})
@@ -174,22 +191,36 @@ export function register(app) {
return
}
const seasonByEpisodeNumber = new Map()
const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Episode numbers can repeat across seasons, so keep every season an episode number appears in.
const feedByEpisodeNumber = new Map()
for (const ep of episodes) {
if (ep.episode && ep.season) seasonByEpisodeNumber.set(ep.episode, ep.season)
if (!ep.episode || !ep.season) continue
const list = feedByEpisodeNumber.get(ep.episode) ?? []
list.push({ season: String(ep.season), title: normalize(ep.title) })
feedByEpisodeNumber.set(ep.episode, list)
}
let updated = 0
const stillMissing = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
if (entry.season) continue
const found = seasonByEpisodeNumber.get(episodeNumber)
if (found) {
entry.season = found
updated += 1
} else {
stillMissing.push(episodeNumber)
const episodeNumber = scriptEpisodeNumber(key, entry)
const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
let match = candidates.length === 1 ? candidates[0] : undefined
// Several seasons share this episode number: fall back to matching the title
if (!match && candidates.length > 1) {
const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title))
if (byTitle.length === 1) match = byTitle[0]
}
const newKey = match ? scriptKey(match.season, episodeNumber) : ''
if (!match || (newKey !== key && state.episodeScripts[newKey])) {
stillMissing.push(episodeNumber)
continue
}
delete state.episodeScripts[key]
state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber }
updated += 1
}
if (updated > 0) queueEpisodeScriptsWrite()
@@ -198,12 +229,12 @@ export function register(app) {
})
// Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!state.episodeScripts[episodeNumber]) {
app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
const key = req.params.key?.trim()
if (!key || !state.episodeScripts[key]) {
res.status(404).json({ message: 'Script not found.' }); return
}
delete state.episodeScripts[episodeNumber]
delete state.episodeScripts[key]
queueEpisodeScriptsWrite()
res.json({ ok: true })
})
+1 -1
View File
@@ -54,7 +54,7 @@ export const state = {
studyCertificates: [],
studyCertificatesWritePromise: Promise.resolve(),
// episodeScripts: { [episodeNumber]: { text, title, filename, season, uploadedAt } }
// episodeScripts: { ["<season>-<episode>" or "<episode>" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } }
episodeScripts: {},
episodeScriptsWritePromise: Promise.resolve(),