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
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:
1 parent
4ddedf0f81
commit
752611d121
7 files changed
+114
-41
No files matched your search
+6
-1
@@ -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 = {}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
|
||||
Reference in new issue
Block a user