mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-10 15:22:04 +00:00
Better handling of TZ and Bot API added
This commit is contained in:
parent
17f38874d1
commit
56ab732f6b
4 changed files with 185 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
www/js/ui.js
43
www/js/ui.js
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue