Show the queued show and its start time in the index.php sidebar

- Shows get a description and an icon upload; broadcasts get a scheduled_at
  start time. schema.sql adds the columns to existing databases and seeds
  the descriptions from the old hand-written sidebar.
- The dashboard and cli/set_show.php take an optional date and Eastern start
  time when queuing; index.php shows it with the UTC conversion.
- liveBroadcast() and currentShow() ignore broadcasts unseen for longer than
  the grace period, so a stopped poller can't leave a show stuck as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel MesserandClaude Opus 5.5 committed 2026-10-30 09:30:20 -05:00
1 parent 3fb49e816c
commit f659b09a0b
9 files changed
+297 -77

No files matched your search

+32 -7
View File
@@ -6,6 +6,14 @@ require __DIR__ . '/../includes/broadcasts.php';
$admin = requireAdmin();
$db = getDB();
// e.g. "Friday, October 30 at 7:30pm Eastern (23:30 UTC)"
function describeScheduledAt(string $utc): string {
$local = utcToStationTime($utc);
$text = $local->format('l, F j \a\t g:ia') . ' ' . STATION_TIMEZONE_LABEL . ' (' . substr($utc, 11, 5) . ' UTC';
if (substr($utc, 0, 10) !== $local->format('Y-m-d')) $text .= ' on ' . (new DateTimeImmutable($utc))->format('l');
return $text . ')';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
$action = $_POST['action'] ?? '';
@@ -14,12 +22,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = $db->prepare('SELECT id, name FROM shows WHERE id = ?');
$stmt->execute([(int)($_POST['show_id'] ?? 0)]);
$show = $stmt->fetch();
$date = trim((string)($_POST['date'] ?? ''));
$time = substr(trim((string)($_POST['time'] ?? '')), 0, 5);
$scheduledAt = $date === '' && $time === '' ? null : stationTimeToUtc("$date $time");
if (!$show) {
flash('Please choose a show.', 'error');
} elseif (setBroadcastShow($db, (int)$show['id']) === 'live') {
} elseif (($date !== '' || $time !== '') && $scheduledAt === null) {
flash('Please enter both a valid date and time, or leave both blank.', 'error');
} elseif (setBroadcastShow($db, (int)$show['id'], $scheduledAt) === 'live') {
flash("The live broadcast is now {$show['name']}.");
} else {
flash("{$show['name']} is queued. It will go live as soon as the stream starts.");
flash("{$show['name']} is queued" . ($scheduledAt ? ' for ' . describeScheduledAt($scheduledAt) : '')
. '. It will go live as soon as the stream starts.');
}
} elseif ($action === 'clear_queue') {
clearQueuedShow($db);
@@ -33,6 +47,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$live = liveBroadcast($db);
$current = currentShow($db);
$shows = $db->query('SELECT id, name FROM shows ORDER BY name')->fetchAll();
$scheduledLocal = !$live && $current && $current['scheduled_at'] ? utcToStationTime($current['scheduled_at']) : null;
adminHeader('Dashboard', $admin);
?>
@@ -41,7 +56,7 @@ adminHeader('Dashboard', $admin);
<?php if ($live): ?>
<strong>Live now</strong><?= $current ? ': ' . e($current['name']) : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC.
<?php elseif ($current): ?>
Offline. <strong><?= e($current['name']) ?></strong> is queued for the next broadcast.
Offline. <strong><?= e($current['name']) ?></strong> is queued for the next broadcast<?= $current['scheduled_at'] ? ', scheduled for ' . e(describeScheduledAt($current['scheduled_at'])) : '' ?>.
<?php else: ?>
Offline. No show is queued.
<?php endif; ?>
@@ -50,12 +65,12 @@ adminHeader('Dashboard', $admin);
<h3><?= $live ? 'Change the live show' : 'Queue the next show' ?></h3>
<p><?= $live
? 'Switch which show the live page and recently played list belong to.'
: 'Choose the show before you start streaming. The broadcast goes live on the site automatically when the stream comes online.' ?></p>
: 'Choose the show before you start streaming. The broadcast goes live on the site automatically when the stream comes online. The show, and its start time if you enter one, appear on the main page until then.' ?></p>
<form method="post" action="index.php">
<?= csrfField() ?>
<input type="hidden" name="action" value="set_show" />
<div class="row gtr-uniform gtr-50">
<div class="col-8 col-12-small">
<div class="<?= $live ? 'col-8' : 'col-12' ?> col-12-small">
<select name="show_id" aria-label="Show" required>
<option value="">Choose a show…</option>
<?php foreach ($shows as $show): ?>
@@ -63,8 +78,18 @@ adminHeader('Dashboard', $admin);
<?php endforeach; ?>
</select>
</div>
<div class="col-4 col-12-small">
<input type="submit" class="primary fit" value="<?= $live ? 'Switch show' : 'Queue show' ?>" />
<?php if (!$live): ?>
<div class="col-6 col-12-xsmall">
<label for="date">Date (optional)</label>
<input type="date" name="date" id="date" value="<?= $scheduledLocal ? $scheduledLocal->format('Y-m-d') : '' ?>" />
</div>
<div class="col-6 col-12-xsmall">
<label for="time">Start time, <?= e(STATION_TIMEZONE_LABEL) ?> (optional)</label>
<input type="time" name="time" id="time" value="<?= $scheduledLocal ? $scheduledLocal->format('H:i') : '' ?>" />
</div>
<?php endif; ?>
<div class="<?= $live ? 'col-4' : 'col-12' ?> col-12-small">
<input type="submit" class="primary<?= $live ? ' fit' : '' ?>" value="<?= $live ? 'Switch show' : 'Queue show' ?>" />
</div>
</div>
</form>
+93 -40
View File
@@ -5,26 +5,50 @@ require __DIR__ . '/../includes/admin_layout.php';
$admin = requireAdmin();
$db = getDB();
const BACKGROUND_DIR = __DIR__ . '/../images/backgrounds/';
// Backgrounds and icons are both saved here
const UPLOAD_DIR = __DIR__ . '/../images/backgrounds/';
const MAX_BACKGROUND_BYTES = 5 * 1024 * 1024;
const MAX_ICON_BYTES = 1024 * 1024;
const MAX_DESCRIPTION_CHARS = 5000;
// Uploads are identified by their content, not their filename, and saved under a generated name
const BACKGROUND_TYPES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
const IMAGE_TYPES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
function slugify(string $name): string {
$slug = strtolower(str_replace(["'", '’'], '', $name));
return trim(preg_replace('/[^a-z0-9]+/', '-', $slug), '-');
}
function deleteBackground(?string $filename): void {
if ($filename && is_file(BACKGROUND_DIR . basename($filename))) {
unlink(BACKGROUND_DIR . basename($filename));
function deleteUpload(?string $filename): void {
if ($filename && is_file(UPLOAD_DIR . basename($filename))) {
unlink(UPLOAD_DIR . basename($filename));
}
}
// Checks an optional image upload. Returns its file extension, or null if no file was sent or it was rejected
// (rejections are added to $errors).
function validateImageUpload(?array $upload, string $label, int $maxBytes, array &$errors): ?string {
if (!$upload || $upload['error'] === UPLOAD_ERR_NO_FILE) return null;
if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > $maxBytes) {
$errors[] = "{$label}s can be up to " . ($maxBytes / 1024 / 1024) . ' MB.';
return null;
}
if ($upload['error'] !== UPLOAD_ERR_OK) {
$errors[] = "The {$label} failed to upload. Please try again.";
return null;
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
$ext = IMAGE_TYPES[$mime] ?? null;
if ($ext === null || getimagesize($upload['tmp_name']) === false) {
$errors[] = "{$label}s must be JPEG, PNG or WebP.";
return null;
}
return $ext;
}
$id = (int)($_GET['id'] ?? 0);
$show = ['id' => 0, 'name' => '', 'slug' => '', 'background_image' => null];
$show = ['id' => 0, 'name' => '', 'slug' => '', 'description' => '', 'icon_image' => null, 'background_image' => null];
if ($id) {
$stmt = $db->prepare('SELECT id, name, slug, background_image FROM shows WHERE id = ?');
$stmt = $db->prepare('SELECT id, name, slug, description, icon_image, background_image FROM shows WHERE id = ?');
$stmt->execute([$id]);
$show = $stmt->fetch();
if (!$show) {
@@ -38,13 +62,14 @@ $errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// PHP discards the whole form when a request exceeds post_max_size, so catch that before the CSRF check
if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
$errors[] = 'That upload is too large. Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.';
$errors[] = 'That upload is too large. Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB and icons up to ' . (MAX_ICON_BYTES / 1024 / 1024) . ' MB.';
} else {
verifyCsrf();
if (($_POST['action'] ?? '') === 'delete' && $id) {
$db->prepare('DELETE FROM shows WHERE id = ?')->execute([$id]);
deleteBackground($show['background_image']);
deleteUpload($show['background_image']);
deleteUpload($show['icon_image']);
flash("Deleted {$show['name']}.");
header('Location: shows.php');
exit;
@@ -52,6 +77,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$show['name'] = trim((string)($_POST['name'] ?? ''));
$show['slug'] = trim((string)($_POST['slug'] ?? '')) ?: slugify($show['name']);
// Normalise line endings so blank-line paragraph breaks are found on index.php
$show['description'] = trim(str_replace("\r\n", "\n", (string)($_POST['description'] ?? '')));
if ($show['name'] === '' || mb_strlen($show['name']) > 255) {
$errors[] = 'Name is required (up to 255 characters).';
@@ -63,46 +90,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$show['slug'], $id]);
if ($stmt->fetchColumn() > 0) $errors[] = "Another show already uses the slug '{$show['slug']}'.";
}
if (mb_strlen($show['description']) > MAX_DESCRIPTION_CHARS) {
$errors[] = 'The description can be up to ' . MAX_DESCRIPTION_CHARS . ' characters.';
}
$upload = $_FILES['background'] ?? null;
$uploadExt = null;
if ($upload && $upload['error'] !== UPLOAD_ERR_NO_FILE) {
if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > MAX_BACKGROUND_BYTES) {
$errors[] = 'Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.';
} elseif ($upload['error'] !== UPLOAD_ERR_OK) {
$errors[] = 'The background image failed to upload. Please try again.';
} else {
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
$uploadExt = BACKGROUND_TYPES[$mime] ?? null;
if ($uploadExt === null || getimagesize($upload['tmp_name']) === false) {
$errors[] = 'Background images must be JPEG, PNG or WebP.';
// column => [form field, label, size limit, filename suffix after the slug]
$images = [
'background_image' => ['background', 'background image', MAX_BACKGROUND_BYTES, ''],
'icon_image' => ['icon', 'icon', MAX_ICON_BYTES, '-icon'],
];
$uploadExts = [];
foreach ($images as $column => [$field, $label, $maxBytes]) {
$uploadExts[$column] = validateImageUpload($_FILES[$field] ?? null, ucfirst($label), $maxBytes, $errors);
}
$oldImages = ['background_image' => $show['background_image'], 'icon_image' => $show['icon_image']];
if (!$errors) {
foreach ($images as $column => [$field, $label, , $suffix]) {
if ($uploadExts[$column] !== null) {
$filename = $show['slug'] . $suffix . '-' . bin2hex(random_bytes(4)) . '.' . $uploadExts[$column];
if (move_uploaded_file($_FILES[$field]['tmp_name'], UPLOAD_DIR . $filename)) {
$show[$column] = $filename;
} else {
$errors[] = "The {$label} could not be saved. Check that the web server can write to images/backgrounds/.";
}
} elseif (!empty($_POST["remove_{$field}"])) {
$show[$column] = null;
}
}
// Don't leave a newly saved file behind if the other one failed
if ($errors) {
foreach ($oldImages as $column => $old) {
if ($show[$column] !== $old) deleteUpload($show[$column]);
$show[$column] = $old;
}
}
}
if (!$errors) {
$oldBackground = $show['background_image'];
if ($uploadExt !== null) {
$filename = $show['slug'] . '-' . bin2hex(random_bytes(4)) . '.' . $uploadExt;
if (move_uploaded_file($upload['tmp_name'], BACKGROUND_DIR . $filename)) {
$show['background_image'] = $filename;
} else {
$errors[] = 'The background image could not be saved. Check that the web server can write to images/backgrounds/.';
}
} elseif (!empty($_POST['remove_background'])) {
$show['background_image'] = null;
}
}
if (!$errors) {
$description = $show['description'] === '' ? null : $show['description'];
if ($id) {
$db->prepare('UPDATE shows SET name = ?, slug = ?, background_image = ? WHERE id = ?')
->execute([$show['name'], $show['slug'], $show['background_image'], $id]);
$db->prepare('UPDATE shows SET name = ?, slug = ?, description = ?, icon_image = ?, background_image = ? WHERE id = ?')
->execute([$show['name'], $show['slug'], $description, $show['icon_image'], $show['background_image'], $id]);
} else {
$db->prepare('INSERT INTO shows (name, slug, background_image) VALUES (?, ?, ?)')
->execute([$show['name'], $show['slug'], $show['background_image']]);
$db->prepare('INSERT INTO shows (name, slug, description, icon_image, background_image) VALUES (?, ?, ?, ?, ?)')
->execute([$show['name'], $show['slug'], $description, $show['icon_image'], $show['background_image']]);
}
foreach ($oldImages as $column => $old) {
if ($old !== $show[$column]) deleteUpload($old);
}
if ($oldBackground !== $show['background_image']) deleteBackground($oldBackground);
flash("Saved {$show['name']}.");
header('Location: shows.php');
@@ -129,6 +165,23 @@ adminHeader($id ? 'Edit Show' : 'Add a Show', $admin);
<input type="text" name="slug" id="slug" value="<?= e($show['slug']) ?>" maxlength="64" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="Leave blank to generate from the name" />
<p><small>Short name used by <code>cli/set_show.php</code>. Lowercase letters, numbers and dashes.</small></p>
</div>
<div class="col-12">
<label for="description">Description</label>
<textarea name="description" id="description" rows="8" maxlength="<?= MAX_DESCRIPTION_CHARS ?>"><?= e($show['description'] ?? '') ?></textarea>
<p><small>Shown in the sidebar of the main page while this show is queued or live. Plain text; leave a blank line between paragraphs.</small></p>
</div>
<div class="col-12">
<label for="icon">Icon</label>
<?php if ($show['icon_image']): ?>
<p><img src="../images/backgrounds/<?= e(rawurlencode($show['icon_image'])) ?>" alt="Current icon" style="max-width:120px;border-radius:4px;display:block;"></p>
<?php endif; ?>
<input type="file" name="icon" id="icon" accept="image/jpeg,image/png,image/webp" />
<p><small>JPEG, PNG or WebP, up to <?= MAX_ICON_BYTES / 1024 / 1024 ?> MB. The show logo shown next to the description on the main page.</small></p>
<?php if ($show['icon_image']): ?>
<input type="checkbox" name="remove_icon" id="remove_icon" value="1" />
<label for="remove_icon">Remove icon</label>
<?php endif; ?>
</div>
<div class="col-12">
<label for="background">Background image</label>
<?php if ($show['background_image']): ?>