Add Nuggets Episode Workflow to admin (listen, cold open, record, edit, Spotify); v2.13.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff119e7f0
commit
ffb3551b7e
5 files changed
+320
-4
No files matched your search
@@ -0,0 +1,130 @@
|
||||
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.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user