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>
242 lines
9.3 KiB
JavaScript
242 lines
9.3 KiB
JavaScript
import mammoth from 'mammoth'
|
|
import { requireAdminAuth } from '../auth.js'
|
|
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, '\\$&')
|
|
}
|
|
|
|
// Word-boundary containment so "sin" doesn't match inside "using"/"resin".
|
|
function containsWord(haystackLower, word) {
|
|
return new RegExp(`\\b${escapeRegExp(word)}\\b`, 'i').test(haystackLower)
|
|
}
|
|
|
|
/**
|
|
* Whitespace-normalised search over episode script text, with word-boundary
|
|
* matching, an exact-phrase score boost, and a "season N" browse mode.
|
|
* Returns { episodeNumber, title, filename, season, snippet, score } for matches.
|
|
*/
|
|
function searchScripts(query) {
|
|
if (!query || query.trim().length < 2) return []
|
|
const q = query.trim()
|
|
const qLower = q.toLowerCase()
|
|
|
|
// "season N" browses every script tagged with that season, no text match needed.
|
|
const seasonQuery = /^season\s+(\d+)$/i.exec(q)
|
|
if (seasonQuery) {
|
|
const season = seasonQuery[1]
|
|
return Object.entries(state.episodeScripts)
|
|
.filter(([, entry]) => String(entry.season ?? '') === season)
|
|
.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)
|
|
}
|
|
|
|
const words = qLower.split(/\s+/).filter(w => w.length >= 2)
|
|
if (words.length === 0) return []
|
|
const results = []
|
|
|
|
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))
|
|
if (matchedWords.length === 0) continue
|
|
|
|
// Exact multi-word phrase matches rank above partial word-overlap matches.
|
|
const isExactPhrase = words.length > 1 && text.includes(qLower)
|
|
const score = isExactPhrase ? 1 : matchedWords.length / words.length
|
|
|
|
// Find a snippet around the first matching word
|
|
const firstWord = matchedWords[0]
|
|
const idx = text.indexOf(firstWord)
|
|
const start = Math.max(0, idx - 80)
|
|
const end = Math.min(entry.text.length, idx + 180)
|
|
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
|
if (start > 0) snippet = '…' + snippet
|
|
if (end < entry.text.length) snippet = snippet + '…'
|
|
|
|
results.push({
|
|
key,
|
|
episodeNumber,
|
|
title: entry.title ?? `Episode ${episodeNumber}`,
|
|
filename: entry.filename ?? '',
|
|
season: entry.season ?? null,
|
|
snippet,
|
|
score,
|
|
})
|
|
}
|
|
|
|
return results.sort((a, b) => b.score - a.score).slice(0, 8)
|
|
}
|
|
|
|
export function register(app) {
|
|
// Public script search — called by the frontend global search
|
|
app.get('/api/episode-scripts/search', (req, res) => {
|
|
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
if (!q || q.length < 2) { res.json({ results: [] }); return }
|
|
res.json({ results: searchScripts(q) })
|
|
})
|
|
|
|
// Admin: list all uploaded scripts
|
|
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
|
|
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,
|
|
uploadedAt: entry.uploadedAt ?? null,
|
|
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
|
|
}))
|
|
list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber))
|
|
res.json({ scripts: list })
|
|
})
|
|
|
|
// Admin: upload a docx script for an episode
|
|
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
|
|
}
|
|
|
|
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
|
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
|
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
|
|
const seasonRaw = typeof req.body?.season === 'string' ? req.body.season.trim() : (typeof req.body?.season === 'number' ? String(req.body.season) : '')
|
|
const season = /^\d+$/.test(seasonRaw) ? seasonRaw : null
|
|
|
|
if (!dataUrl.startsWith('data:')) {
|
|
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
|
}
|
|
|
|
const base64 = dataUrl.split(',')[1] ?? ''
|
|
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
|
|
|
|
const buffer = Buffer.from(base64, 'base64')
|
|
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
|
|
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
|
|
}
|
|
|
|
let text
|
|
try {
|
|
const result = await mammoth.extractRawText({ buffer })
|
|
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
|
} catch (err) {
|
|
console.error('[episode-scripts] mammoth extraction failed:', err.message)
|
|
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
|
|
}
|
|
|
|
if (!text || text.length < 10) {
|
|
res.status(422).json({ message: 'No readable text found in document.' }); return
|
|
}
|
|
|
|
// Trim to max length
|
|
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
|
|
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
|
|
}
|
|
|
|
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,
|
|
text,
|
|
uploadedAt: new Date().toISOString(),
|
|
wordCount: text.split(/\s+/).filter(Boolean).length,
|
|
}
|
|
queueEpisodeScriptsWrite()
|
|
|
|
res.json({
|
|
ok: true,
|
|
key,
|
|
episodeNumber,
|
|
season,
|
|
wordCount: state.episodeScripts[key].wordCount,
|
|
})
|
|
})
|
|
|
|
// Admin: fill in season numbers for existing scripts from the podcast RSS feed
|
|
// (matches by episode number; never overwrites a season that's already set)
|
|
app.post('/api/admin-episode-scripts/backfill-seasons', requireAdminAuth, async (_req, res) => {
|
|
let episodes
|
|
try {
|
|
episodes = await fetchAllEpisodes()
|
|
} catch (err) {
|
|
console.error('[episode-scripts] backfill: RSS fetch failed:', err.message)
|
|
res.status(502).json({ message: 'Could not fetch the podcast RSS feed to look up seasons.' })
|
|
return
|
|
}
|
|
|
|
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) 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 [key, entry] of Object.entries({ ...state.episodeScripts })) {
|
|
if (entry.season) continue
|
|
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()
|
|
|
|
res.json({ ok: true, updated, stillMissing })
|
|
})
|
|
|
|
// Admin: delete a script
|
|
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[key]
|
|
queueEpisodeScriptsWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
}
|