192 lines
7.3 KiB
JavaScript
192 lines
7.3 KiB
JavaScript
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 ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
|
} catch {
|
|
return { planned: [], items: {}, laneOverrides: {}, laneSeasons: {} }
|
|
}
|
|
}
|
|
|
|
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
|
|
|
|
function emptyState() {
|
|
return {
|
|
steps: Object.fromEntries(STEPS.map(s => [s, null])),
|
|
notes: '',
|
|
}
|
|
}
|
|
|
|
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
|
function laneSeasonOf(lane, wf) {
|
|
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) : ''
|
|
}
|
|
|
|
function laneFor(season, wf) {
|
|
const s = String(season ?? '')
|
|
if (s && s === laneSeasonOf('A', wf)) return 'A'
|
|
if (s && s === laneSeasonOf('B', wf)) 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, wf),
|
|
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, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
|
|
})
|
|
|
|
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 })
|
|
})
|
|
|
|
// 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()
|
|
for (const lane of ['A', 'B']) {
|
|
const v = req.body?.[lane]
|
|
if (v === undefined) continue
|
|
const str = String(v).trim()
|
|
if (str === '') delete wf.laneSeasons[lane]
|
|
else if (/^\d+$/.test(str)) wf.laneSeasons[lane] = str
|
|
else { res.status(400).json({ message: 'Season must be a whole number.' }); return }
|
|
}
|
|
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 })
|
|
})
|
|
}
|