mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-10 07:12:05 +00:00
Add a scheduled show concept to the project without bot API for now
This commit is contained in:
parent
4d61a68e8b
commit
17f38874d1
10 changed files with 1005 additions and 1 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
var Config = require("./config");
|
var Config = require("./config");
|
||||||
var db = require("./database");
|
var db = require("./database");
|
||||||
var Promise = require("bluebird");
|
var Promise = require("bluebird");
|
||||||
|
const shows = require('./shows');
|
||||||
|
|
||||||
const LOGGER = require('@calzoneman/jsli')('bgtask');
|
const LOGGER = require('@calzoneman/jsli')('bgtask');
|
||||||
|
|
||||||
|
|
@ -92,6 +93,26 @@ function initAccountCleanup() {
|
||||||
}, 3600 * 1000);
|
}, 3600 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initShowScheduler() {
|
||||||
|
var SCHEDULE_INTERVAL = 15 * 1000;
|
||||||
|
var running = false;
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
if (running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await shows.pollAndRunDueShows();
|
||||||
|
} catch (error) {
|
||||||
|
LOGGER.error('Show scheduler failure: %s', error.stack || error);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}, SCHEDULE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function (Server) {
|
module.exports = function (Server) {
|
||||||
if (init === Server) {
|
if (init === Server) {
|
||||||
LOGGER.warn("Attempted to re-init background tasks");
|
LOGGER.warn("Attempted to re-init background tasks");
|
||||||
|
|
@ -103,4 +124,5 @@ module.exports = function (Server) {
|
||||||
initChannelDumper(Server);
|
initChannelDumper(Server);
|
||||||
initPasswordResetCleanup();
|
initPasswordResetCleanup();
|
||||||
initAccountCleanup();
|
initAccountCleanup();
|
||||||
|
initShowScheduler();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
193
src/database/shows.js
Normal file
193
src/database/shows.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
function knex() {
|
||||||
|
return db.getDB().knex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShowRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
let playlist = [];
|
||||||
|
let recurrenceMeta = null;
|
||||||
|
try {
|
||||||
|
playlist = JSON.parse(row.playlist || '[]');
|
||||||
|
} catch (_err) {
|
||||||
|
playlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recurrenceMeta = row.recurrence_meta ? JSON.parse(row.recurrence_meta) : null;
|
||||||
|
} catch (_err) {
|
||||||
|
recurrenceMeta = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
channel_name: row.channel_name,
|
||||||
|
channel_id: row.channel_id,
|
||||||
|
name: row.name,
|
||||||
|
playlist,
|
||||||
|
timezone: row.timezone,
|
||||||
|
scheduled_for: row.scheduled_for,
|
||||||
|
next_run_at: row.next_run_at,
|
||||||
|
status: row.status,
|
||||||
|
recurrence: row.recurrence,
|
||||||
|
recurrence_meta: recurrenceMeta,
|
||||||
|
fill_mode: row.fill_mode,
|
||||||
|
conflict_mode: row.conflict_mode,
|
||||||
|
start_playback: !!row.start_playback,
|
||||||
|
run_count: row.run_count,
|
||||||
|
last_run_at: row.last_run_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
created_by: row.created_by,
|
||||||
|
updated_by: row.updated_by,
|
||||||
|
last_error: row.last_error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeShowInput(input) {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
playlist: JSON.stringify(input.playlist || []),
|
||||||
|
timezone: input.timezone,
|
||||||
|
scheduled_for: input.scheduled_for,
|
||||||
|
next_run_at: input.next_run_at,
|
||||||
|
status: input.status,
|
||||||
|
recurrence: input.recurrence,
|
||||||
|
recurrence_meta: input.recurrence_meta ? JSON.stringify(input.recurrence_meta) : null,
|
||||||
|
fill_mode: input.fill_mode,
|
||||||
|
conflict_mode: input.conflict_mode,
|
||||||
|
start_playback: !!input.start_playback,
|
||||||
|
last_error: input.last_error || null,
|
||||||
|
updated_by: input.updated_by,
|
||||||
|
updated_at: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listShows(channelId) {
|
||||||
|
const rows = await knex()('channel_shows')
|
||||||
|
.where({ channel_id: channelId })
|
||||||
|
.orderBy('next_run_at', 'asc')
|
||||||
|
.orderBy('created_at', 'desc')
|
||||||
|
.select();
|
||||||
|
return rows.map(parseShowRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShowById(id, channelId) {
|
||||||
|
const rows = await knex()('channel_shows')
|
||||||
|
.where({ id, channel_id: channelId })
|
||||||
|
.select();
|
||||||
|
return parseShowRow(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShow({ channelId, createdBy, input }) {
|
||||||
|
const now = Date.now();
|
||||||
|
const row = serializeShowInput({
|
||||||
|
...input,
|
||||||
|
updated_by: createdBy,
|
||||||
|
last_error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [id] = await knex()('channel_shows').insert({
|
||||||
|
channel_id: channelId,
|
||||||
|
created_by: createdBy,
|
||||||
|
created_at: now,
|
||||||
|
run_count: 0,
|
||||||
|
...row
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateShow({ id, channelId, input }) {
|
||||||
|
const row = serializeShowInput(input);
|
||||||
|
await knex()('channel_shows')
|
||||||
|
.where({ id, channel_id: channelId })
|
||||||
|
.update(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteShow(id, channelId) {
|
||||||
|
await knex()('channel_shows')
|
||||||
|
.where({ id, channel_id: channelId })
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateShowStatus({ id, channelId, status, updatedBy, lastError = null }) {
|
||||||
|
await knex()('channel_shows')
|
||||||
|
.where({ id, channel_id: channelId })
|
||||||
|
.update({
|
||||||
|
status,
|
||||||
|
last_error: lastError,
|
||||||
|
updated_by: updatedBy,
|
||||||
|
updated_at: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimDueShows(limit = 20) {
|
||||||
|
const now = Date.now();
|
||||||
|
const rows = await knex()('channel_shows')
|
||||||
|
.join('channels', 'channel_shows.channel_id', 'channels.id')
|
||||||
|
.where({ status: 'scheduled' })
|
||||||
|
.andWhere('next_run_at', '<=', now)
|
||||||
|
.orderBy('channel_shows.next_run_at', 'asc')
|
||||||
|
.limit(limit)
|
||||||
|
.select('channel_shows.*', 'channels.name as channel_name');
|
||||||
|
|
||||||
|
const claimed = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const updated = await knex()('channel_shows')
|
||||||
|
.where({ id: row.id, status: 'scheduled' })
|
||||||
|
.andWhere('next_run_at', '<=', now)
|
||||||
|
.update({
|
||||||
|
status: 'running',
|
||||||
|
updated_at: Date.now(),
|
||||||
|
last_error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
claimed.push(parseShowRow({ ...row, status: 'running' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeRun({ id, recurrence, nextRunAt, updatedBy }) {
|
||||||
|
const patch = {
|
||||||
|
status: recurrence === 'none' ? 'completed' : 'scheduled',
|
||||||
|
next_run_at: recurrence === 'none' ? nextRunAt : nextRunAt,
|
||||||
|
run_count: knex().raw('run_count + 1'),
|
||||||
|
last_run_at: Date.now(),
|
||||||
|
updated_by: updatedBy,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
last_error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
await knex()('channel_shows')
|
||||||
|
.where({ id })
|
||||||
|
.update(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failRun({ id, updatedBy, error }) {
|
||||||
|
await knex()('channel_shows')
|
||||||
|
.where({ id })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
last_error: error,
|
||||||
|
updated_by: updatedBy,
|
||||||
|
updated_at: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listShows,
|
||||||
|
getShowById,
|
||||||
|
createShow,
|
||||||
|
updateShow,
|
||||||
|
deleteShow,
|
||||||
|
updateShowStatus,
|
||||||
|
claimDueShows,
|
||||||
|
completeRun,
|
||||||
|
failRun
|
||||||
|
};
|
||||||
|
|
@ -175,6 +175,36 @@ export async function initTables() {
|
||||||
t.index('channel_id');
|
t.index('channel_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureTable('channel_shows', t => {
|
||||||
|
t.charset('utf8');
|
||||||
|
t.increments('id').notNullable().primary();
|
||||||
|
t.integer('channel_id')
|
||||||
|
.unsigned()
|
||||||
|
.notNullable()
|
||||||
|
.references('id').inTable('channels')
|
||||||
|
.onDelete('cascade');
|
||||||
|
t.string('name', 100).notNullable();
|
||||||
|
t.specificType('playlist', 'mediumtext character set utf8mb4 not null');
|
||||||
|
t.string('timezone', 64).notNullable().defaultTo('UTC');
|
||||||
|
t.bigInteger('scheduled_for').notNullable();
|
||||||
|
t.bigInteger('next_run_at').notNullable();
|
||||||
|
t.string('status', 20).notNullable().defaultTo('draft');
|
||||||
|
t.string('recurrence', 20).notNullable().defaultTo('none');
|
||||||
|
t.specificType('recurrence_meta', 'text character set utf8mb4');
|
||||||
|
t.string('fill_mode', 20).notNullable().defaultTo('append');
|
||||||
|
t.string('conflict_mode', 20).notNullable().defaultTo('force');
|
||||||
|
t.boolean('start_playback').notNullable().defaultTo(false);
|
||||||
|
t.integer('run_count').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('last_run_at').nullable();
|
||||||
|
t.bigInteger('created_at').notNullable();
|
||||||
|
t.bigInteger('updated_at').notNullable();
|
||||||
|
t.string('created_by', 20).notNullable();
|
||||||
|
t.string('updated_by', 20).notNullable();
|
||||||
|
t.specificType('last_error', 'text character set utf8mb4');
|
||||||
|
t.index(['channel_id', 'status', 'next_run_at'], 'channel_shows_due_idx');
|
||||||
|
t.index(['channel_id', 'created_at'], 'channel_shows_channel_created_idx');
|
||||||
|
});
|
||||||
|
|
||||||
await ensureTable('banned_channels', t => {
|
await ensureTable('banned_channels', t => {
|
||||||
t.charset('utf8mb4');
|
t.charset('utf8mb4');
|
||||||
t.string('channel_name', 30)
|
t.string('channel_name', 30)
|
||||||
|
|
|
||||||
119
src/shows.js
Normal file
119
src/shows.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
const LOGGER = require('@calzoneman/jsli')('shows');
|
||||||
|
const util = require('./utilities');
|
||||||
|
const showDB = require('./database/shows');
|
||||||
|
const Server = require('./server');
|
||||||
|
|
||||||
|
function makeSystemProxy(name) {
|
||||||
|
const rank = 5;
|
||||||
|
return {
|
||||||
|
effectiveRank: rank,
|
||||||
|
account: { effectiveRank: rank },
|
||||||
|
getName: () => name,
|
||||||
|
getLowerName: () => name.toLowerCase(),
|
||||||
|
is: () => true,
|
||||||
|
isAnonymous: () => false,
|
||||||
|
queueLimiter: util.newRateLimiter(),
|
||||||
|
socket: {
|
||||||
|
emit: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNextRunAt(show) {
|
||||||
|
const base = Number(show.next_run_at || show.scheduled_for || Date.now());
|
||||||
|
if (show.recurrence === 'daily') {
|
||||||
|
return base + 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show.recurrence === 'weekly') {
|
||||||
|
return base + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaylist(rawPlaylist) {
|
||||||
|
if (!Array.isArray(rawPlaylist)) return [];
|
||||||
|
return rawPlaylist
|
||||||
|
.map(item => ({
|
||||||
|
id: item && item.id ? String(item.id).trim() : '',
|
||||||
|
type: item && item.type ? String(item.type).trim() : '',
|
||||||
|
pos: item && item.pos === 'next' ? 'next' : 'end'
|
||||||
|
}))
|
||||||
|
.filter(item => item.id && item.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShowToChannel(chan, show) {
|
||||||
|
const playlist = normalizePlaylist(show.playlist);
|
||||||
|
if (playlist.length === 0) {
|
||||||
|
throw new Error('Show playlist is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const plmod = chan.modules.playlist;
|
||||||
|
|
||||||
|
if (show.conflict_mode === 'skip' && plmod.items.length > 0) {
|
||||||
|
throw new Error('Conflict mode is skip and playlist is not empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorName = '[show:' + show.id + ']';
|
||||||
|
const proxy = makeSystemProxy(actorName);
|
||||||
|
|
||||||
|
if (show.fill_mode === 'replace') {
|
||||||
|
plmod.handleClear(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist.forEach((entry, idx) => {
|
||||||
|
plmod.handleQueue(proxy, {
|
||||||
|
id: entry.id,
|
||||||
|
type: entry.type,
|
||||||
|
pos: idx === 0 && entry.pos === 'next' ? 'next' : 'end'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (show.start_playback) {
|
||||||
|
const first = plmod.items.first;
|
||||||
|
if (first) {
|
||||||
|
plmod.handleJumpTo(proxy, first.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runShow(show) {
|
||||||
|
const server = Server.getServer();
|
||||||
|
if (!server || !server.isChannelLoaded(show.channel_name)) {
|
||||||
|
throw new Error('Channel is not currently active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chan = server.getChannel(show.channel_name);
|
||||||
|
applyShowToChannel(chan, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAndRunDueShows() {
|
||||||
|
const due = await showDB.claimDueShows(20);
|
||||||
|
for (const show of due) {
|
||||||
|
try {
|
||||||
|
await runShow(show);
|
||||||
|
const nextRun = computeNextRunAt(show);
|
||||||
|
await showDB.completeRun({
|
||||||
|
id: show.id,
|
||||||
|
recurrence: show.recurrence,
|
||||||
|
nextRunAt: nextRun,
|
||||||
|
updatedBy: '[scheduler]'
|
||||||
|
});
|
||||||
|
LOGGER.info('Executed show %s on channel %s', show.id, show.channel_name);
|
||||||
|
} catch (error) {
|
||||||
|
await showDB.failRun({
|
||||||
|
id: show.id,
|
||||||
|
updatedBy: '[scheduler]',
|
||||||
|
error: error.message || 'Unknown execution error'
|
||||||
|
});
|
||||||
|
LOGGER.error('Failed to execute show %s: %s', show.id, error.stack || error.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
pollAndRunDueShows,
|
||||||
|
runShow,
|
||||||
|
computeNextRunAt
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ router.use('/channels/:channel/bots', require('./bots'));
|
||||||
router.use('/channels/:channel/emotes', require('./emotes'));
|
router.use('/channels/:channel/emotes', require('./emotes'));
|
||||||
router.use('/channels/:channel/playlist', require('./playlist'));
|
router.use('/channels/:channel/playlist', require('./playlist'));
|
||||||
router.use('/channels/:channel/settings', require('./settings'));
|
router.use('/channels/:channel/settings', require('./settings'));
|
||||||
|
router.use('/channels/:channel/shows', require('./shows'));
|
||||||
router.use('/channels/:channel', require('./moderation'));
|
router.use('/channels/:channel', require('./moderation'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
262
src/web/routes/api/shows.js
Normal file
262
src/web/routes/api/shows.js
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
const express = require('express');
|
||||||
|
const webserver = require('../../webserver');
|
||||||
|
const showDB = require('../../../database/shows');
|
||||||
|
const shows = require('../../../shows');
|
||||||
|
const { getChannelRow, getUserEffectiveRank } = require('./middleware');
|
||||||
|
|
||||||
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
const SHOW_STATUSES = new Set(['draft', 'scheduled', 'paused', 'running', 'completed', 'failed', 'canceled']);
|
||||||
|
const RECURRENCES = new Set(['none', 'daily', 'weekly']);
|
||||||
|
const FILL_MODES = new Set(['append', 'replace']);
|
||||||
|
const CONFLICT_MODES = new Set(['force', 'skip']);
|
||||||
|
|
||||||
|
function sanitizePlaylist(list) {
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list
|
||||||
|
.map(item => ({
|
||||||
|
id: item && item.id ? String(item.id).trim() : '',
|
||||||
|
type: item && item.type ? String(item.type).trim() : '',
|
||||||
|
pos: item && item.pos === 'next' ? 'next' : 'end'
|
||||||
|
}))
|
||||||
|
.filter(item => item.id && item.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSchedule(input) {
|
||||||
|
const ms = Date.parse(input);
|
||||||
|
if (isNaN(ms)) return null;
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateShowPayload(body, old = null) {
|
||||||
|
const name = (body.name || (old && old.name) || '').trim();
|
||||||
|
if (!name || name.length > 100) {
|
||||||
|
return { error: 'Show name must be 1-100 characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = body.playlist !== undefined ? sanitizePlaylist(body.playlist) : (old ? old.playlist : []);
|
||||||
|
if (!Array.isArray(playlist) || playlist.length === 0) {
|
||||||
|
return { error: 'Show playlist must contain at least one item' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = String(body.timezone || (old && old.timezone) || 'UTC').trim();
|
||||||
|
const scheduledInput = body.scheduled_for !== undefined ? body.scheduled_for : (old ? old.scheduled_for : null);
|
||||||
|
const scheduledFor = typeof scheduledInput === 'number' ? scheduledInput : parseSchedule(scheduledInput);
|
||||||
|
if (!scheduledFor) {
|
||||||
|
return { error: 'scheduled_for must be a valid date or timestamp' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurrence = String(body.recurrence || (old && old.recurrence) || 'none');
|
||||||
|
if (!RECURRENCES.has(recurrence)) {
|
||||||
|
return { error: 'Invalid recurrence' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillMode = String(body.fill_mode || (old && old.fill_mode) || 'append');
|
||||||
|
if (!FILL_MODES.has(fillMode)) {
|
||||||
|
return { error: 'Invalid fill_mode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictMode = String(body.conflict_mode || (old && old.conflict_mode) || 'force');
|
||||||
|
if (!CONFLICT_MODES.has(conflictMode)) {
|
||||||
|
return { error: 'Invalid conflict_mode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPlayback = body.start_playback !== undefined
|
||||||
|
? !!body.start_playback
|
||||||
|
: old
|
||||||
|
? !!old.start_playback
|
||||||
|
: false;
|
||||||
|
|
||||||
|
let status = String(body.status || (old && old.status) || 'scheduled');
|
||||||
|
if (!SHOW_STATUSES.has(status)) {
|
||||||
|
return { error: 'Invalid status' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'running') {
|
||||||
|
status = 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRunAt = status === 'scheduled' ? scheduledFor : (old ? old.next_run_at : scheduledFor);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: {
|
||||||
|
name,
|
||||||
|
playlist,
|
||||||
|
timezone,
|
||||||
|
scheduled_for: scheduledFor,
|
||||||
|
next_run_at: nextRunAt,
|
||||||
|
status,
|
||||||
|
recurrence,
|
||||||
|
recurrence_meta: null,
|
||||||
|
fill_mode: fillMode,
|
||||||
|
conflict_mode: conflictMode,
|
||||||
|
start_playback: startPlayback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeChannel(req, res) {
|
||||||
|
const user = await webserver.authorize(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelRow;
|
||||||
|
try {
|
||||||
|
channelRow = await getChannelRow(req.params.channel);
|
||||||
|
} catch (_err) {
|
||||||
|
res.status(404).json({ error: 'Channel not found' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = await getUserEffectiveRank(user, channelRow);
|
||||||
|
if (rank < 2) {
|
||||||
|
res.status(403).json({ error: 'Insufficient rank' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, channelRow, rank };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const auth = await authorizeChannel(req, res);
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
const showsList = await showDB.listShows(auth.channelRow.id);
|
||||||
|
res.json(showsList);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const auth = await authorizeChannel(req, res);
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
const validated = validateShowPayload(req.body || null);
|
||||||
|
if (validated.error) return res.status(400).json({ error: validated.error });
|
||||||
|
|
||||||
|
const id = await showDB.createShow({
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
createdBy: auth.user.name,
|
||||||
|
input: validated.value
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||||
|
res.status(201).json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/: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 current = await showDB.getShowById(id, auth.channelRow.id);
|
||||||
|
if (!current) return res.status(404).json({ error: 'Show not found' });
|
||||||
|
|
||||||
|
const validated = validateShowPayload(req.body || {}, current);
|
||||||
|
if (validated.error) return res.status(400).json({ error: validated.error });
|
||||||
|
|
||||||
|
await showDB.updateShow({
|
||||||
|
id,
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
input: {
|
||||||
|
...validated.value,
|
||||||
|
updated_by: auth.user.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/: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 current = await showDB.getShowById(id, auth.channelRow.id);
|
||||||
|
if (!current) return res.status(404).json({ error: 'Show not found' });
|
||||||
|
|
||||||
|
await showDB.deleteShow(id, auth.channelRow.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/action', 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' });
|
||||||
|
|
||||||
|
const action = String((req.body && req.body.action) || '').toLowerCase();
|
||||||
|
if (!action) return res.status(400).json({ error: 'action is required' });
|
||||||
|
|
||||||
|
if (action === 'pause') {
|
||||||
|
await showDB.updateShowStatus({
|
||||||
|
id,
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
status: 'paused',
|
||||||
|
updatedBy: auth.user.name
|
||||||
|
});
|
||||||
|
} else if (action === 'resume') {
|
||||||
|
await showDB.updateShow({
|
||||||
|
id,
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
input: {
|
||||||
|
...show,
|
||||||
|
status: 'scheduled',
|
||||||
|
next_run_at: Date.now(),
|
||||||
|
updated_by: auth.user.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (action === 'cancel') {
|
||||||
|
await showDB.updateShowStatus({
|
||||||
|
id,
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
status: 'canceled',
|
||||||
|
updatedBy: auth.user.name
|
||||||
|
});
|
||||||
|
} else if (action === 'run') {
|
||||||
|
try {
|
||||||
|
const forced = {
|
||||||
|
...show,
|
||||||
|
channel_name: auth.channelRow.name,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
await shows.runShow(forced);
|
||||||
|
const nextRun = shows.computeNextRunAt(forced);
|
||||||
|
await showDB.completeRun({
|
||||||
|
id,
|
||||||
|
recurrence: show.recurrence,
|
||||||
|
nextRunAt: nextRun,
|
||||||
|
updatedBy: auth.user.name
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error.message || 'Failed to execute show' });
|
||||||
|
}
|
||||||
|
} else if (action === 'schedule') {
|
||||||
|
await showDB.updateShow({
|
||||||
|
id,
|
||||||
|
channelId: auth.channelRow.id,
|
||||||
|
input: {
|
||||||
|
...show,
|
||||||
|
status: 'scheduled',
|
||||||
|
next_run_at: show.scheduled_for,
|
||||||
|
updated_by: auth.user.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Unknown action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -222,7 +222,8 @@ html(lang="en")
|
||||||
li: a(href="#cs-permedit", data-toggle="tab", tabindex="-1") Permissions
|
li: a(href="#cs-permedit", data-toggle="tab", tabindex="-1") Permissions
|
||||||
li: a(href="#cs-chanranks", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestChannelRanks')") Moderators
|
li: a(href="#cs-chanranks", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestChannelRanks')") Moderators
|
||||||
li: a(href="#cs-banlist", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestBanlist')") Ban list
|
li: a(href="#cs-banlist", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestBanlist')") Ban list
|
||||||
li: a(href="#cs-bots", data-toggle="tab", tabindex="-1", onclick="javascript:CSTBots.load()") Bots
|
li: a(href="#cs-bots", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTBots&&CSTBots.load){CSTBots.load()}") Bots
|
||||||
|
li: a(href="#cs-shows", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTShows&&CSTShows.load){CSTShows.load()}") Shows
|
||||||
li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log
|
li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log
|
||||||
.modal-body
|
.modal-body
|
||||||
.tab-content
|
.tab-content
|
||||||
|
|
@ -238,6 +239,7 @@ html(lang="en")
|
||||||
+chatfilters()
|
+chatfilters()
|
||||||
+emotes()
|
+emotes()
|
||||||
+bots()
|
+bots()
|
||||||
|
+shows()
|
||||||
+chanlog()
|
+chanlog()
|
||||||
+permeditor()
|
+permeditor()
|
||||||
.modal-footer
|
.modal-footer
|
||||||
|
|
|
||||||
|
|
@ -253,5 +253,74 @@ mixin bots
|
||||||
th Last connected
|
th Last connected
|
||||||
tbody#cs-bots-list
|
tbody#cs-bots-list
|
||||||
|
|
||||||
|
mixin shows
|
||||||
|
#cs-shows.tab-pane
|
||||||
|
h4 Shows
|
||||||
|
p Create scheduled playlist runs. Each show stores playlist items and executes at the scheduled time.
|
||||||
|
form.form-horizontal(action="javascript:void(0)", role="form")
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-name") Name
|
||||||
|
.col-sm-9
|
||||||
|
input#cs-shows-name.form-control(type="text", placeholder="Show name", maxlength="100")
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-scheduled-for") Scheduled For
|
||||||
|
.col-sm-9
|
||||||
|
input#cs-shows-scheduled-for.form-control(type="datetime-local")
|
||||||
|
.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")
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-recurrence") Recurrence
|
||||||
|
.col-sm-9
|
||||||
|
select#cs-shows-recurrence.form-control
|
||||||
|
option(value="none") One-time
|
||||||
|
option(value="daily") Daily
|
||||||
|
option(value="weekly") Weekly
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-fill-mode") Fill Mode
|
||||||
|
.col-sm-9
|
||||||
|
select#cs-shows-fill-mode.form-control
|
||||||
|
option(value="append") Append to queue
|
||||||
|
option(value="replace") Replace queue
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-conflict-mode") Conflict Mode
|
||||||
|
.col-sm-9
|
||||||
|
select#cs-shows-conflict-mode.form-control
|
||||||
|
option(value="force") Force run
|
||||||
|
option(value="skip") Skip if queue not empty
|
||||||
|
.form-group
|
||||||
|
.col-sm-9.col-sm-offset-3
|
||||||
|
.checkbox
|
||||||
|
label(for="cs-shows-start-playback")
|
||||||
|
input#cs-shows-start-playback(type="checkbox")
|
||||||
|
| Start playback immediately
|
||||||
|
.form-group
|
||||||
|
label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist
|
||||||
|
.col-sm-9
|
||||||
|
.input-group(style="margin-bottom:8px")
|
||||||
|
input#cs-shows-mediaurl.form-control(type="text", placeholder="Media URL")
|
||||||
|
span.input-group-btn
|
||||||
|
button#cs-shows-add-next.btn.btn-default(type="button") Add Next
|
||||||
|
span.input-group-btn
|
||||||
|
button#cs-shows-add-end.btn.btn-default(type="button") Add End
|
||||||
|
ul#cs-shows-playlist-list.videolist(style="max-height:220px; overflow:auto")
|
||||||
|
p.text-muted.small(style="margin-top:6px") Build the show playlist like the main channel queue. Drag to reorder, use controls to remove/move.
|
||||||
|
.form-group
|
||||||
|
.col-sm-9.col-sm-offset-3
|
||||||
|
button#cs-shows-create.btn.btn-primary Create Show
|
||||||
|
button#cs-shows-update.btn.btn-default(type="button") Update Selected
|
||||||
|
button#cs-shows-clear.btn.btn-default(type="button") Clear Form
|
||||||
|
table.table.table-striped.table-condensed(style="margin-top:12px")
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
th Name
|
||||||
|
th Status
|
||||||
|
th Next Run
|
||||||
|
th Timezone
|
||||||
|
th Recurrence
|
||||||
|
th Actions
|
||||||
|
tbody#cs-shows-list
|
||||||
|
|
||||||
mixin permeditor
|
mixin permeditor
|
||||||
#cs-permedit.tab-pane
|
#cs-permedit.tab-pane
|
||||||
|
|
|
||||||
305
www/js/ui.js
305
www/js/ui.js
|
|
@ -1173,3 +1173,308 @@ var CSTBots = (function () {
|
||||||
|
|
||||||
return { load: load };
|
return { load: load };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
var CSTShows = (function () {
|
||||||
|
var selectedId = null;
|
||||||
|
var draftPlaylist = [];
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
return '/api/v1/channels/' + CHANNEL.name + '/shows';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateInput(ms) {
|
||||||
|
if (!ms) return '';
|
||||||
|
var d = new Date(ms);
|
||||||
|
var pad = function (n) { return String(n).padStart(2, '0'); };
|
||||||
|
return d.getFullYear() + '-' +
|
||||||
|
pad(d.getMonth() + 1) + '-' +
|
||||||
|
pad(d.getDate()) + 'T' +
|
||||||
|
pad(d.getHours()) + ':' +
|
||||||
|
pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDraftPlaylist() {
|
||||||
|
var ul = $('#cs-shows-playlist-list').empty();
|
||||||
|
if (!draftPlaylist.length) {
|
||||||
|
ul.append('<li class="queue_entry text-muted">No items in show playlist</li>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draftPlaylist.forEach(function (item, idx) {
|
||||||
|
var li = $('<li class="queue_entry">').attr('data-idx', idx);
|
||||||
|
var title = item.title || (item.type + ':' + item.id);
|
||||||
|
$('<span>').text('[' + item.type + '] ' + title).appendTo(li);
|
||||||
|
var controls = $('<div class="btn-group pull-right">').appendTo(li);
|
||||||
|
$('<button class="btn btn-xs btn-default" type="button" title="Move up">')
|
||||||
|
.html('<span class="glyphicon glyphicon-arrow-up"></span>')
|
||||||
|
.on('click', function () {
|
||||||
|
if (idx <= 0) return;
|
||||||
|
var tmp = draftPlaylist[idx - 1];
|
||||||
|
draftPlaylist[idx - 1] = draftPlaylist[idx];
|
||||||
|
draftPlaylist[idx] = tmp;
|
||||||
|
renderDraftPlaylist();
|
||||||
|
})
|
||||||
|
.appendTo(controls);
|
||||||
|
$('<button class="btn btn-xs btn-default" type="button" title="Move down">')
|
||||||
|
.html('<span class="glyphicon glyphicon-arrow-down"></span>')
|
||||||
|
.on('click', function () {
|
||||||
|
if (idx >= draftPlaylist.length - 1) return;
|
||||||
|
var tmp = draftPlaylist[idx + 1];
|
||||||
|
draftPlaylist[idx + 1] = draftPlaylist[idx];
|
||||||
|
draftPlaylist[idx] = tmp;
|
||||||
|
renderDraftPlaylist();
|
||||||
|
})
|
||||||
|
.appendTo(controls);
|
||||||
|
$('<button class="btn btn-xs btn-danger" type="button" title="Remove">')
|
||||||
|
.html('<span class="glyphicon glyphicon-remove"></span>')
|
||||||
|
.on('click', function () {
|
||||||
|
draftPlaylist.splice(idx, 1);
|
||||||
|
renderDraftPlaylist();
|
||||||
|
})
|
||||||
|
.appendTo(controls);
|
||||||
|
ul.append(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUrlToDraft(pos) {
|
||||||
|
var raw = $('#cs-shows-mediaurl').val();
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var links = raw.trim().split(/\s+/).filter(function (x) { return x.trim() !== ''; });
|
||||||
|
if (links.length === 0) return;
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
var duplicates = 0;
|
||||||
|
var parseFail = 0;
|
||||||
|
|
||||||
|
links.forEach(function (link) {
|
||||||
|
var media = parseMediaLink(link);
|
||||||
|
if (!media || !media.id || !media.type) {
|
||||||
|
parseFail++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDupe = draftPlaylist.some(function (item) {
|
||||||
|
return item.id === media.id && item.type === media.type;
|
||||||
|
});
|
||||||
|
if (isDupe) {
|
||||||
|
duplicates++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = {
|
||||||
|
id: media.id,
|
||||||
|
type: media.type,
|
||||||
|
title: media.id,
|
||||||
|
pos: pos === 'next' ? 'next' : 'end'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pos === 'next') {
|
||||||
|
draftPlaylist.unshift(entry);
|
||||||
|
} else {
|
||||||
|
draftPlaylist.push(entry);
|
||||||
|
}
|
||||||
|
added++;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#cs-shows-mediaurl').val('');
|
||||||
|
renderDraftPlaylist();
|
||||||
|
|
||||||
|
if (parseFail > 0 || duplicates > 0) {
|
||||||
|
var parts = [];
|
||||||
|
if (added > 0) parts.push('added ' + added);
|
||||||
|
if (duplicates > 0) parts.push('skipped duplicates ' + duplicates);
|
||||||
|
if (parseFail > 0) parts.push('failed to parse ' + parseFail);
|
||||||
|
alert(parts.join(', '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFormPayload() {
|
||||||
|
var scheduledRaw = $('#cs-shows-scheduled-for').val();
|
||||||
|
var timezone = $('#cs-shows-timezone').val().trim();
|
||||||
|
if (!timezone) {
|
||||||
|
timezone = 'UTC';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: $('#cs-shows-name').val().trim(),
|
||||||
|
scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null,
|
||||||
|
timezone: timezone,
|
||||||
|
recurrence: $('#cs-shows-recurrence').val(),
|
||||||
|
fill_mode: $('#cs-shows-fill-mode').val(),
|
||||||
|
conflict_mode: $('#cs-shows-conflict-mode').val(),
|
||||||
|
start_playback: $('#cs-shows-start-playback').prop('checked'),
|
||||||
|
playlist: draftPlaylist.map(function (item) {
|
||||||
|
return { id: item.id, type: item.type, pos: item.pos || 'end' };
|
||||||
|
}),
|
||||||
|
status: 'scheduled'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
selectedId = null;
|
||||||
|
$('#cs-shows-name').val('');
|
||||||
|
$('#cs-shows-scheduled-for').val('');
|
||||||
|
var detectedTz = 'UTC';
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||||
|
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
}
|
||||||
|
$('#cs-shows-timezone').val(detectedTz);
|
||||||
|
$('#cs-shows-recurrence').val('none');
|
||||||
|
$('#cs-shows-fill-mode').val('append');
|
||||||
|
$('#cs-shows-conflict-mode').val('force');
|
||||||
|
$('#cs-shows-start-playback').prop('checked', false);
|
||||||
|
$('#cs-shows-mediaurl').val('');
|
||||||
|
draftPlaylist = [];
|
||||||
|
renderDraftPlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectShow(show) {
|
||||||
|
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');
|
||||||
|
$('#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');
|
||||||
|
$('#cs-shows-start-playback').prop('checked', !!show.start_playback);
|
||||||
|
draftPlaylist = (show.playlist || []).map(function (item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
title: item.id,
|
||||||
|
pos: item.pos || 'end'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
renderDraftPlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(id, actionName) {
|
||||||
|
$.ajax({
|
||||||
|
url: apiBase() + '/' + id + '/action',
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ action: actionName })
|
||||||
|
}).done(function () {
|
||||||
|
load();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
alert('Failed action: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(shows) {
|
||||||
|
var tbody = $('#cs-shows-list').empty();
|
||||||
|
if (!shows.length) {
|
||||||
|
tbody.append('<tr><td colspan="6" class="text-muted">No shows configured</td></tr>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shows.forEach(function (show) {
|
||||||
|
var row = $('<tr>');
|
||||||
|
row.append($('<td>').append(
|
||||||
|
$('<a href=\"javascript:void(0)\">').text(show.name).on('click', function () { selectShow(show); })
|
||||||
|
));
|
||||||
|
row.append($('<td>').text(show.status));
|
||||||
|
row.append($('<td>').text(show.next_run_at ? new Date(show.next_run_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A'));
|
||||||
|
row.append($('<td>').text(show.timezone || 'UTC'));
|
||||||
|
row.append($('<td>').text(show.recurrence || 'none'));
|
||||||
|
|
||||||
|
var actions = $('<td>');
|
||||||
|
$('<button class=\"btn btn-xs btn-primary\" style=\"margin-right:4px\">Run</button>')
|
||||||
|
.on('click', function () { action(show.id, 'run'); })
|
||||||
|
.appendTo(actions);
|
||||||
|
$('<button class=\"btn btn-xs btn-default\" style=\"margin-right:4px\">Pause</button>')
|
||||||
|
.on('click', function () { action(show.id, 'pause'); })
|
||||||
|
.appendTo(actions);
|
||||||
|
$('<button class=\"btn btn-xs btn-success\" style=\"margin-right:4px\">Resume</button>')
|
||||||
|
.on('click', function () { action(show.id, 'resume'); })
|
||||||
|
.appendTo(actions);
|
||||||
|
$('<button class=\"btn btn-xs btn-warning\" style=\"margin-right:4px\">Cancel</button>')
|
||||||
|
.on('click', function () { action(show.id, 'cancel'); })
|
||||||
|
.appendTo(actions);
|
||||||
|
$('<button class=\"btn btn-xs btn-danger\">Delete</button>')
|
||||||
|
.on('click', function () {
|
||||||
|
if (!confirm('Delete this show?')) return;
|
||||||
|
$.ajax({ url: apiBase() + '/' + show.id, method: 'DELETE' })
|
||||||
|
.done(load)
|
||||||
|
.fail(function (xhr) {
|
||||||
|
alert('Delete failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.appendTo(actions);
|
||||||
|
row.append(actions);
|
||||||
|
tbody.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
$.getJSON(apiBase(), render).fail(function () {
|
||||||
|
$('#cs-shows-list').html('<tr><td colspan=\"6\" class=\"text-danger\">Failed to load shows</td></tr>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#cs-shows-create').on('click', function () {
|
||||||
|
var payload = readFormPayload();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: apiBase(),
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify(payload)
|
||||||
|
}).done(function () {
|
||||||
|
clearForm();
|
||||||
|
load();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
alert('Create failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#cs-shows-update').on('click', function () {
|
||||||
|
if (!selectedId) {
|
||||||
|
alert('Select a show first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = readFormPayload();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: apiBase() + '/' + selectedId,
|
||||||
|
method: 'PUT',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify(payload)
|
||||||
|
}).done(function () {
|
||||||
|
load();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
alert('Update failed: ' + ((xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#cs-shows-add-next').on('click', function () { addUrlToDraft('next'); });
|
||||||
|
$('#cs-shows-add-end').on('click', function () { addUrlToDraft('end'); });
|
||||||
|
$('#cs-shows-mediaurl').on('keyup', function (ev) {
|
||||||
|
if (ev.keyCode === 13) {
|
||||||
|
addUrlToDraft('end');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#cs-shows-clear').on('click', clearForm);
|
||||||
|
$('#cs-shows-playlist-list').sortable({
|
||||||
|
update: function () {
|
||||||
|
var nextDraft = [];
|
||||||
|
$('#cs-shows-playlist-list > li').each(function () {
|
||||||
|
var idx = parseInt($(this).attr('data-idx'), 10);
|
||||||
|
if (!isNaN(idx) && draftPlaylist[idx]) {
|
||||||
|
nextDraft.push(draftPlaylist[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (nextDraft.length === draftPlaylist.length) {
|
||||||
|
draftPlaylist = nextDraft;
|
||||||
|
renderDraftPlaylist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).disableSelection();
|
||||||
|
renderDraftPlaylist();
|
||||||
|
clearForm();
|
||||||
|
|
||||||
|
return { load: load };
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1012,6 +1012,7 @@ function handleModPermissions() {
|
||||||
setParentVisible("a[href='#cs-emotes']", hasPermission("emoteedit"));
|
setParentVisible("a[href='#cs-emotes']", hasPermission("emoteedit"));
|
||||||
setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
|
setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
|
||||||
setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
|
setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
|
||||||
|
setParentVisible("a[href='#cs-shows']", CLIENT.rank >= 2);
|
||||||
$("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
|
$("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
|
||||||
$("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
|
$("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue