import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { Readable } from 'node:stream' import path from 'node:path' import { writeFile } from '../atomic-write.js' import { requireAdminAuth } from '../auth.js' import { loadHistory } from './nuggets-host-upload.js' import { DATA_DIR, SEAFILE_URL, SEAFILE_TOKEN, SEAFILE_NUGGETS_REPO_ID, SEAFILE_EPISODE_UPLOAD_DIR, } from '../config.js' const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json') export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify'] async function loadWorkflow() { try { return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) } catch { return { items: {} } } } function entryId(entry) { return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16) } function emptyState() { return { steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time coldOpenText: '', notes: '', spotifyUrl: '', } } function seafileConfigured() { return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID) } export function register(app) { app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => { const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()]) const items = history.entries.map(entry => { const id = entryId(entry) return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } } }) res.json({ items }) }) app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => { const { id } = req.params const history = await loadHistory() if (!history.entries.some(e => entryId(e) === id)) { res.status(404).json({ message: 'Upload not found.' }) return } const wf = await loadWorkflow() const prev = { ...emptyState(), ...(wf.items[id] ?? {}) } const body = req.body ?? {} const next = { ...prev, steps: { ...prev.steps } } if (body.steps && typeof body.steps === 'object') { for (const s of WORKFLOW_STEPS) { if (typeof body.steps[s] === 'boolean') { // Keep the original completion time if the step was already done next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null } } } if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000) if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000) if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500) wf.items[id] = next await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8') res.json({ ok: true, state: next }) }) // Stream the uploaded audio from Seafile so it can be auditioned in the admin. app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => { if (!seafileConfigured()) { res.status(503).json({ message: 'Seafile is not configured.' }) return } const history = await loadHistory() const entry = history.entries.find(e => entryId(e) === req.params.id) if (!entry) { res.status(404).json({ message: 'Upload not found.' }) return } try { const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}` const linkRes = await fetch( `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`, { headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) }, ) if (!linkRes.ok) { res.status(404).json({ message: 'Audio file not found in Seafile.' }) return } const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '') const headers = {} if (req.headers.range) headers.Range = req.headers.range const upstream = await fetch(downloadUrl, { headers }) if (!upstream.ok && upstream.status !== 206) { res.status(502).json({ message: 'Could not fetch audio from Seafile.' }) return } res.status(upstream.status) for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { const v = upstream.headers.get(h) if (v) res.setHeader(h, v) } if (!upstream.headers.get('content-type')) res.setHeader('content-type', 'audio/mpeg') const stream = Readable.fromWeb(upstream.body) req.on('close', () => stream.destroy()) stream.on('error', () => res.end()) stream.pipe(res) } catch (err) { console.error('[nuggets-workflow] audio error:', err.message) if (!res.headersSent) res.status(500).json({ message: 'Audio stream failed.' }) } }) }