// Recorder scheduler — CRUD for upcoming + historic recording windows. // // The actual start/stop transitions happen in src/scheduler.js; this route // just owns the recorder_schedules rows. import express from 'express'; import pool from '../db/pool.js'; import { validateUuid } from '../middleware/errors.js'; const router = express.Router(); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']); const TERMINAL = new Set(['completed', 'failed', 'cancelled']); function rowToJson(r) { return { id: r.id, name: r.name, recorder_id: r.recorder_id, recorder_name: r.recorder_name || null, start_at: r.start_at, end_at: r.end_at, recurrence: r.recurrence, status: r.status, last_asset_id: r.last_asset_id, error_message: r.error_message, created_at: r.created_at, updated_at: r.updated_at, }; } const ALLOWED_STATUS_FILTER = new Set(['all', 'upcoming', 'past']); // GET /api/v1/schedules?status=upcoming|past|all router.get('/', async (req, res, next) => { try { const status = (req.query.status || 'all').toLowerCase(); if (!ALLOWED_STATUS_FILTER.has(status)) { return res.status(400).json({ error: `status must be one of: ${[...ALLOWED_STATUS_FILTER].join(', ')}` }); } let where = 'TRUE'; if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`; else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`; const result = await pool.query( `SELECT s.*, r.name AS recorder_name FROM recorder_schedules s LEFT JOIN recorders r ON r.id = s.recorder_id WHERE ${where} ORDER BY s.start_at ASC LIMIT 200` ); res.json({ schedules: result.rows.map(rowToJson) }); } catch (err) { next(err); } }); // POST /api/v1/schedules router.post('/', async (req, res, next) => { try { const { name, recorder_id, start_at, end_at, recurrence } = req.body || {}; if (!name || !recorder_id || !start_at || !end_at) { return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' }); } const rec = (recurrence || 'none').toLowerCase(); if (!ALLOWED_RECURRENCE.has(rec)) { return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` }); } if (new Date(end_at) <= new Date(start_at)) { return res.status(400).json({ error: 'end_at must be after start_at' }); } // Make sure the recorder exists before binding to it. const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]); if (rExists.rows.length === 0) { return res.status(400).json({ error: 'Unknown recorder_id' }); } const ins = await pool.query( `INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status) VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING *`, [name.trim(), recorder_id, start_at, end_at, rec] ); res.status(201).json(rowToJson(ins.rows[0])); } catch (err) { next(err); } }); // PUT /api/v1/schedules/:id — edit a not-yet-started schedule router.put('/:id', async (req, res, next) => { try { const { id } = req.params; const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]); if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' }); if (current.rows[0].status === 'running') { return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' }); } const fields = ['name','start_at','end_at','recurrence']; const updates = []; const values = []; let i = 1; for (const f of fields) { if (req.body[f] !== undefined) { if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) { return res.status(400).json({ error: 'invalid recurrence' }); } updates.push(`${f} = $${i++}`); values.push(req.body[f]); } } if (updates.length === 0) return res.json(rowToJson(current.rows[0])); updates.push('updated_at = NOW()'); values.push(id); const result = await pool.query( `UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`, values ); res.json(rowToJson(result.rows[0])); } catch (err) { next(err); } }); // POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule router.post('/:id/cancel', async (req, res, next) => { try { const { id } = req.params; const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]); if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' }); if (TERMINAL.has(cur.rows[0].status)) { return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` }); } // Just mark as cancelled — the tick loop will stop the recorder if it's // currently running and the schedule has just been cancelled. const result = await pool.query( `UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW() WHERE id = $1 RETURNING *`, [id] ); res.json(rowToJson(result.rows[0])); } catch (err) { next(err); } }); // DELETE /api/v1/schedules/:id — hard delete (terminal schedules only) router.delete('/:id', async (req, res, next) => { try { const { id } = req.params; const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]); if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' }); if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') { return res.status(400).json({ error: 'Cancel a running schedule before deleting' }); } await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]); res.json({ message: 'Schedule deleted' }); } catch (err) { next(err); } }); export default router;