import { readFile } from 'node:fs/promises' import path from 'node:path' import { writeFile } from '../atomic-write.js' import { requireAdminAuth } from '../auth.js' import { state } from '../state.js' import { DATA_DIR } from '../config.js' import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js' import { fetchAllEpisodes } from './episodes.js' const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json') const STEPS = ['readScript', 'record', 'edit', 'upload'] async function load() { try { const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {} } } catch { return { planned: [], items: {}, laneOverrides: {} } } } const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8') function emptyState() { return { steps: Object.fromEntries(STEPS.map(s => [s, null])), notes: '', } } function laneFor(season) { const content = state.cachedSiteContent ?? {} const s = String(season ?? '') if (s && s === String(content.seriesSeason ?? '')) return 'A' if (s && s === String(content.secondSeries?.season ?? '')) return 'B' return 'other' } const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() const plannedKey = p => p.key ?? scriptKey(p.season, p.episodeNumber) function toIsoDate(pubDate) { const d = new Date(pubDate) return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10) } export function register(app) { app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => { const wf = await load() let feed = [] let feedOk = true try { feed = await fetchAllEpisodes() } catch { feedOk = false } const byKey = new Map() const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch }) for (const [key, entry] of Object.entries(state.episodeScripts ?? {})) { upsert(key, { season: entry.season ?? '', episodeNumber: scriptEpisodeNumber(key, entry), title: entry.title ?? '', hasScript: true, }) } for (const p of wf.planned) { const key = plannedKey(p) const prev = byKey.get(key) upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true, plannedLane: p.lane }) } for (const item of feed) { const hasNum = /^\d+$/.test(item.episode) let key if (item.season && hasNum) { key = scriptKey(item.season, item.episode) } else { // No season in the feed: pair with a script/planned episode of the same title, else keep it on its own. const t = normTitle(item.title) const match = [...byKey.values()].find(e => !e.released && normTitle(e.title) === t) key = match ? match.key : `t:${t}` } const prev = byKey.get(key) upsert(key, { season: item.season || prev?.season || '', episodeNumber: hasNum ? item.episode : (prev?.episodeNumber ?? ''), title: item.title || prev?.title || '', released: true, releaseDate: toIsoDate(item.pubDate), spotifyUrl: item.link || '', }) } const items = [...byKey.values()].map(e => ({ ...e, lane: wf.laneOverrides[e.key] ?? e.plannedLane ?? laneFor(e.season), laneOverride: wf.laneOverrides[e.key] ?? '', released: Boolean(e.released), state: { ...emptyState(), ...(wf.items[e.key] ?? {}) }, })) items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0)) res.json({ items, feedOk }) }) app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => { const season = /^\d+$/.test(String(req.body?.season ?? '')) ? String(req.body.season) : '' const episodeNumber = String(req.body?.episodeNumber ?? '').trim() const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : '' if (!/^\d+$/.test(episodeNumber)) { res.status(400).json({ message: 'Episode number must be a whole number.' }) return } const lane = ['A', 'B'].includes(req.body?.lane) ? req.body.lane : 'other' const wf = await load() // Without a season number the key must still be unique per lane. const key = season ? scriptKey(season, episodeNumber) : `${lane}-p${episodeNumber}` if (!wf.planned.some(p => plannedKey(p) === key)) { wf.planned.push({ key, season, episodeNumber, title, lane }) await save(wf) } res.json({ ok: true, key }) }) app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => { const wf = await load() wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key) delete wf.items[req.params.key] delete wf.laneOverrides[req.params.key] await save(wf) res.json({ ok: true }) }) // Assign an episode to a lane by hand ('auto' clears the override and goes back to season matching). app.put('/api/admin/podcast-workflow/:key/lane', requireAdminAuth, async (req, res) => { const lane = req.body?.lane if (!['A', 'B', 'other', 'auto'].includes(lane)) { res.status(400).json({ message: 'Lane must be A, B, other or auto.' }) return } const wf = await load() if (lane === 'auto') delete wf.laneOverrides[req.params.key] else wf.laneOverrides[req.params.key] = lane await save(wf) res.json({ ok: true }) }) app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => { const { key } = req.params const wf = await load() const prev = { ...emptyState(), ...(wf.items[key] ?? {}) } const next = { ...prev, steps: { ...prev.steps } } const body = req.body ?? {} if (body.steps && typeof body.steps === 'object') { for (const s of STEPS) { if (typeof body.steps[s] === 'boolean') { next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null } } } if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000) wf.items[key] = next await save(wf) res.json({ ok: true, state: next }) }) }