Better handling of TZ and Bot API added

This commit is contained in:
Speng Reb 2026-05-20 21:00:48 +02:00
parent 17f38874d1
commit 56ab732f6b
4 changed files with 185 additions and 13 deletions

View file

@ -362,6 +362,77 @@ Update one or more settings. Unknown keys are silently ignored.
---
### Shows
Show endpoints manage scheduled playlist runs. These endpoints support bot Bearer auth and session auth.
#### `GET /channels/:channel/shows`
List shows for the channel. Minimum rank: **2 (Mod)**.
#### `GET /channels/:channel/shows/:id`
Get a single show. Minimum rank: **2 (Mod)**.
#### `POST /channels/:channel/shows`
Create a show. Minimum rank: **2 (Mod)**.
#### `PUT /channels/:channel/shows/:id`
Update a show. Minimum rank: **2 (Mod)**.
#### `DELETE /channels/:channel/shows/:id`
Delete a show. Minimum rank: **3 (Admin)**.
#### `POST /channels/:channel/shows/:id/action`
Run control action.
| Action | Minimum rank |
|------------|--------------|
| `pause` | 2 |
| `resume` | 2 |
| `schedule` | 2 |
| `run` | 3 |
| `cancel` | 3 |
**Create/Update body schema:**
```json
{
"name": "Friday Prime",
"scheduled_for": "2026-05-22T19:00:00.000Z",
"timezone": "America/New_York",
"recurrence": "weekly",
"fill_mode": "replace",
"conflict_mode": "force",
"start_playback": true,
"playlist": [
{ "type": "yt", "id": "dQw4w9WgXcQ", "pos": "end" }
],
"status": "scheduled"
}
```
**Field constraints:**
- `timezone`: required IANA timezone string (example: `Europe/Berlin`, `America/New_York`)
- `recurrence`: `none | daily | weekly`
- `fill_mode`: `append | replace`
- `conflict_mode`: `force | skip`
- `playlist`: non-empty array of media entries (`type`, `id`, optional `pos: next|end`)
- `status`: one of `draft | scheduled | paused | completed | failed | canceled` (`running` is internal)
**Action body schema:**
```json
{ "action": "run" }
```
---
### Bot management
These endpoints use **session cookie auth** (the normal logged-in web session), not a bot token. They are intended for the channel settings UI.

View file

@ -2,7 +2,8 @@ const express = require('express');
const webserver = require('../../webserver');
const showDB = require('../../../database/shows');
const shows = require('../../../shows');
const { getChannelRow, getUserEffectiveRank } = require('./middleware');
const botDB = require('../../../database/bots');
const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware');
const router = express.Router({ mergeParams: true });
@ -10,6 +11,13 @@ const SHOW_STATUSES = new Set(['draft', 'scheduled', 'paused', 'running', 'compl
const RECURRENCES = new Set(['none', 'daily', 'weekly']);
const FILL_MODES = new Set(['append', 'replace']);
const CONFLICT_MODES = new Set(['force', 'skip']);
const ACTION_MIN_RANK = {
pause: 2,
resume: 2,
schedule: 2,
run: 3,
cancel: 3
};
function sanitizePlaylist(list) {
if (!Array.isArray(list)) return [];
@ -28,6 +36,16 @@ function parseSchedule(input) {
return ms;
}
function isValidTimeZone(tz) {
if (!tz || typeof tz !== 'string') return false;
try {
Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
return true;
} catch (_err) {
return false;
}
}
function validateShowPayload(body, old = null) {
const name = (body.name || (old && old.name) || '').trim();
if (!name || name.length > 100) {
@ -40,6 +58,9 @@ function validateShowPayload(body, old = null) {
}
const timezone = String(body.timezone || (old && old.timezone) || 'UTC').trim();
if (!isValidTimeZone(timezone)) {
return { error: 'timezone must be a valid IANA time zone string' };
}
const scheduledInput = body.scheduled_for !== undefined ? body.scheduled_for : (old ? old.scheduled_for : null);
const scheduledFor = typeof scheduledInput === 'number' ? scheduledInput : parseSchedule(scheduledInput);
if (!scheduledFor) {
@ -96,6 +117,33 @@ function validateShowPayload(body, old = null) {
}
async function authorizeChannel(req, res) {
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
if (!token.startsWith('cbt_')) {
res.status(401).json({ error: 'Invalid token format' });
return null;
}
const tokenHash = hashToken(token);
const bot = await botDB.getBotByTokenHash(tokenHash);
if (!bot) {
res.status(401).json({ error: 'Invalid or revoked token' });
return null;
}
if (bot.channel_name.toLowerCase() !== req.params.channel.toLowerCase()) {
res.status(403).json({ error: 'Token not authorized for this channel' });
return null;
}
return {
actorName: bot.name,
rank: bot.rank,
channelRow: { id: bot.channel_id, name: bot.channel_name }
};
}
const user = await webserver.authorize(req);
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
@ -116,7 +164,7 @@ async function authorizeChannel(req, res) {
return null;
}
return { user, channelRow, rank };
return { user, actorName: user.name, channelRow, rank };
}
router.get('/', async (req, res) => {
@ -127,6 +175,17 @@ router.get('/', async (req, res) => {
res.json(showsList);
});
router.get('/:id', async (req, res) => {
const auth = await authorizeChannel(req, res);
if (!auth) return;
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' });
const show = await showDB.getShowById(id, auth.channelRow.id);
if (!show) return res.status(404).json({ error: 'Show not found' });
res.json(show);
});
router.post('/', async (req, res) => {
const auth = await authorizeChannel(req, res);
if (!auth) return;
@ -136,7 +195,7 @@ router.post('/', async (req, res) => {
const id = await showDB.createShow({
channelId: auth.channelRow.id,
createdBy: auth.user.name,
createdBy: auth.actorName,
input: validated.value
});
@ -162,7 +221,7 @@ router.put('/:id', async (req, res) => {
channelId: auth.channelRow.id,
input: {
...validated.value,
updated_by: auth.user.name
updated_by: auth.actorName
}
});
@ -173,6 +232,7 @@ router.put('/:id', async (req, res) => {
router.delete('/:id', async (req, res) => {
const auth = await authorizeChannel(req, res);
if (!auth) return;
if (auth.rank < 3) return res.status(403).json({ error: 'Insufficient rank' });
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' });
@ -196,13 +256,15 @@ router.post('/:id/action', async (req, res) => {
const action = String((req.body && req.body.action) || '').toLowerCase();
if (!action) return res.status(400).json({ error: 'action is required' });
if (!ACTION_MIN_RANK[action]) return res.status(400).json({ error: 'Unknown action' });
if (auth.rank < ACTION_MIN_RANK[action]) return res.status(403).json({ error: 'Insufficient rank' });
if (action === 'pause') {
await showDB.updateShowStatus({
id,
channelId: auth.channelRow.id,
status: 'paused',
updatedBy: auth.user.name
updatedBy: auth.actorName
});
} else if (action === 'resume') {
await showDB.updateShow({
@ -212,7 +274,7 @@ router.post('/:id/action', async (req, res) => {
...show,
status: 'scheduled',
next_run_at: Date.now(),
updated_by: auth.user.name
updated_by: auth.actorName
}
});
} else if (action === 'cancel') {
@ -220,7 +282,7 @@ router.post('/:id/action', async (req, res) => {
id,
channelId: auth.channelRow.id,
status: 'canceled',
updatedBy: auth.user.name
updatedBy: auth.actorName
});
} else if (action === 'run') {
try {
@ -235,7 +297,7 @@ router.post('/:id/action', async (req, res) => {
id,
recurrence: show.recurrence,
nextRunAt: nextRun,
updatedBy: auth.user.name
updatedBy: auth.actorName
});
} catch (error) {
return res.status(400).json({ error: error.message || 'Failed to execute show' });
@ -248,11 +310,9 @@ router.post('/:id/action', async (req, res) => {
...show,
status: 'scheduled',
next_run_at: show.scheduled_for,
updated_by: auth.user.name
updated_by: auth.actorName
}
});
} else {
return res.status(400).json({ error: 'Unknown action' });
}
const row = await showDB.getShowById(id, auth.channelRow.id);

View file

@ -269,7 +269,7 @@ mixin shows
.form-group
label.control-label.col-sm-3(for="cs-shows-timezone") Timezone
.col-sm-9
input#cs-shows-timezone.form-control(type="text", placeholder="e.g. Europe/Berlin or America/New_York")
select#cs-shows-timezone.form-control
.form-group
label.control-label.col-sm-3(for="cs-shows-recurrence") Recurrence
.col-sm-9

View file

@ -1177,11 +1177,43 @@ var CSTBots = (function () {
var CSTShows = (function () {
var selectedId = null;
var draftPlaylist = [];
var timezoneOptionsLoaded = false;
function apiBase() {
return '/api/v1/channels/' + CHANNEL.name + '/shows';
}
function loadTimezoneOptions() {
if (timezoneOptionsLoaded) return;
timezoneOptionsLoaded = true;
var select = $('#cs-shows-timezone').empty();
var tzs = [];
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
try {
tzs = Intl.supportedValuesOf('timeZone') || [];
} catch (_err) {
tzs = [];
}
}
if (!tzs.length) {
tzs = [
'UTC',
'Europe/Berlin',
'Europe/London',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Asia/Tokyo',
'Asia/Kolkata',
'Australia/Sydney'
];
}
tzs.forEach(function (tz) {
$('<option>').attr('value', tz).text(tz).appendTo(select);
});
}
function toLocalDateInput(ms) {
if (!ms) return '';
var d = new Date(ms);
@ -1313,6 +1345,7 @@ var CSTShows = (function () {
}
function clearForm() {
loadTimezoneOptions();
selectedId = null;
$('#cs-shows-name').val('');
$('#cs-shows-scheduled-for').val('');
@ -1320,6 +1353,9 @@ var CSTShows = (function () {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
if ($('#cs-shows-timezone option[value="' + detectedTz + '"]').length === 0) {
$('<option>').attr('value', detectedTz).text(detectedTz).appendTo('#cs-shows-timezone');
}
$('#cs-shows-timezone').val(detectedTz);
$('#cs-shows-recurrence').val('none');
$('#cs-shows-fill-mode').val('append');
@ -1331,10 +1367,15 @@ var CSTShows = (function () {
}
function selectShow(show) {
loadTimezoneOptions();
selectedId = show.id;
$('#cs-shows-name').val(show.name);
$('#cs-shows-scheduled-for').val(toLocalDateInput(show.scheduled_for));
$('#cs-shows-timezone').val(show.timezone || 'UTC');
var showTz = show.timezone || 'UTC';
if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) {
$('<option>').attr('value', showTz).text(showTz).appendTo('#cs-shows-timezone');
}
$('#cs-shows-timezone').val(showTz);
$('#cs-shows-recurrence').val(show.recurrence || 'none');
$('#cs-shows-fill-mode').val(show.fill_mode || 'append');
$('#cs-shows-conflict-mode').val(show.conflict_mode || 'force');