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
+294 -74

No files matched your search

+14 -12
View File
@@ -20,15 +20,15 @@ The one approved exception is [cli/poll_icecast.php](cli/poll_icecast.php), a cr
## Key Files
Public pages:
- [index.php](index.php) — Main page. Contains the audio player, now-playing widget, upcoming schedule section, and about section. Show information is updated here manually before each broadcast.
- [index.php](index.php) — Main page. The sidebar shows the queued (or live) show from the database: icon, name, scheduled start time in Eastern and UTC, and description. The main column has the about section.
- [live.php](live.php) — Page to send listeners to while streaming. Player on the left, recently played list on the right; the sidebar background and the heading change with the current show.
- [now_playing.php](now_playing.php) — Returns the currently playing track as HTML. Called by `index.php` and `live.php` every 15 seconds.
- [recently_played.php](recently_played.php) — Returns the current broadcast's track history as table rows for `live.php`.
Admin backend ([admin/](admin/)):
- [admin/login.php](admin/login.php), [admin/logout.php](admin/logout.php) — Username/password login.
- [admin/index.php](admin/index.php) — Dashboard: broadcast status, queue the next show, switch the live show, clear the queue.
- [admin/shows.php](admin/shows.php), [admin/show_edit.php](admin/show_edit.php) — List, add, edit and delete shows, including background image uploads.
- [admin/index.php](admin/index.php) — Dashboard: broadcast status, queue the next show (with an optional date and Eastern start time), switch the live show, clear the queue.
- [admin/shows.php](admin/shows.php), [admin/show_edit.php](admin/show_edit.php) — List, add, edit and delete shows, including the description and icon and background image uploads.
Shared includes:
- [includes/db.php](includes/db.php) — `getDB()` returns a shared PDO connection (session time zone UTC).
@@ -39,7 +39,7 @@ Shared includes:
Command-line tools (run over SSH from the site root):
- [cli/poll_icecast.php](cli/poll_icecast.php) — Cron-run poller that records broadcasts and tracks.
- [cli/set_show.php](cli/set_show.php) — Lists shows, or queues/sets the show by slug. Same behaviour as the dashboard control.
- [cli/set_show.php](cli/set_show.php) — Lists shows, or queues/sets the show by slug, optionally with a start time (`php cli/set_show.php <slug> 2026-10-30 19:30`). Same behaviour as the dashboard control.
- [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password.
Other:
@@ -59,13 +59,13 @@ Other:
MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is gitignored and uploaded to the server separately. Tables (see [sql/schema.sql](sql/schema.sql)):
- `shows` — the shows, with a unique `slug` and an optional `background_image` (filename in `images/backgrounds/`).
- `broadcasts` — one row per streaming session: pending (`started_at` NULL, queued from the dashboard or `cli/set_show.php`), live (`ended_at` NULL), or ended.
- `shows` — the shows, with a unique `slug`, a plain-text `description` (blank lines separate paragraphs) and optional `icon_image` and `background_image` (filenames in `images/backgrounds/`).
- `broadcasts` — one row per streaming session: pending (`started_at` NULL, queued from the dashboard or `cli/set_show.php`), live (`ended_at` NULL), or ended. `scheduled_at` is the advertised start time shown on `index.php`; the broadcast still starts whenever the stream comes online.
- `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept.
- `admins` — admin accounts (username + password hash).
- `login_attempts` — failed logins by IP, used for throttling.
All DATETIME values are UTC. `recently_played.php` outputs `<time datetime="...Z">` and `live.php` reformats them in the listener's local time zone.
All DATETIME values are UTC. Show start times are entered and displayed in the station time zone (`STATION_TIMEZONE`, America/New_York, in [includes/broadcasts.php](includes/broadcasts.php)) and converted with `stationTimeToUtc()`/`utcToStationTime()`. `recently_played.php` outputs `<time datetime="...Z">` and `live.php` reformats them in the listener's local time zone.
## Broadcasts and Track History
@@ -73,7 +73,7 @@ All DATETIME values are UTC. `recently_played.php` outputs `<time datetime="...Z
- When the stream comes online, the queued broadcast starts. If nothing was queued, a broadcast with no show is created so tracks are still recorded.
- A new track is recorded whenever title/artist/album differ from the last one, with its album art URL looked up once.
- A broadcast ends once it has been unseen for five minutes, so brief encoder drop-outs don't split a show.
- A broadcast ends once it has been unseen for five minutes, so brief encoder drop-outs don't split a show. `liveBroadcast()` and `currentShow()` apply the same five-minute rule when reading, so if the poller isn't running (e.g. on a dev server without the cron job), a stale broadcast doesn't show as live.
- Tracks are deleted ten minutes after their broadcast ends.
- If the status API can't be reached, the poll is skipped entirely so an outage isn't mistaken for the stream ending.
@@ -88,9 +88,9 @@ When adding admin pages:
- Every POST form must include `csrfField()`, and its handler must call `verifyCsrf()` before doing anything else.
- Use post-redirect-get: handle the POST, call `flash()`, then redirect.
## Background Image Uploads
## Image Uploads
Uploaded through `admin/show_edit.php`: JPEG, PNG or WebP up to 5 MB (PHP's `post_max_size` is 8 MB). Files are validated by content (finfo + `getimagesize`), not extension, and saved to `images/backgrounds/` as `<slug>-<random>.<ext>`. Replaced or removed images are deleted from disk. The web server user must be able to write to that directory. Uploads are gitignored — they belong to each server's database, so they're uploaded per server through the admin page.
Show backgrounds and icons are uploaded through `admin/show_edit.php`: JPEG, PNG or WebP, backgrounds up to 5 MB and icons up to 1 MB (PHP's `post_max_size` is 8 MB). Files are validated by content (finfo + `getimagesize`), not extension, and saved to `images/backgrounds/` as `<slug>-<random>.<ext>` (icons `<slug>-icon-<random>.<ext>`). Replaced or removed images are deleted from disk. The web server user must be able to write to that directory. Uploads are gitignored — they belong to each server's database, so they're uploaded per server through the admin page.
## Conventions
@@ -128,9 +128,11 @@ Server-only setup (one-time, or when the schema changes):
## Updating Show Information
Show info lives in the `#one` section of `index.php`. Previous shows are commented out rather than deleted, so there's a history of past broadcasts in the markup. When updating for a new show, comment out the current show block and add the new one above it.
The `index.php` sidebar is driven by the database. Before a show, queue it on the admin dashboard (or `php cli/set_show.php <slug> <date> <time>`) with its date and Eastern start time; the sidebar then shows its icon, name, start time (with the UTC conversion) and description, switches to "On the Air Now" while live, and falls back to a short "nothing scheduled" note once nothing is queued. Edit descriptions and icons on the show's admin page.
Before going live, queue the show on the admin dashboard (or `php cli/set_show.php <slug>`) and point listeners to `live.php`.
The commented-out show blocks in the `#one` section of `index.php` are the hand-written history from before this; leave them as they are.
Point listeners to `live.php` while streaming.
## Planned Work
+4 -3
View File
@@ -55,7 +55,7 @@ sudo yunohost app setting my_webapp db_user
sudo yunohost app setting my_webapp db_pwd
```
Then apply the schema. It creates the tables and adds the five shows, and it's safe to re-run:
Then apply the schema. It creates the tables and adds the five shows with starting descriptions, and it's safe to re-run. Re-run it after updates that change the schema; it adds any new columns to an existing database:
```bash
cd /var/www/my_webapp/www
@@ -91,7 +91,7 @@ The web server and the cron job both run as the app user, so they can read it. T
## 6. Make the backgrounds folder writable
Show background images are uploaded through the admin pages into `images/backgrounds/`. The app user needs to be able to write there:
Show background images and icons are uploaded through the admin pages into `images/backgrounds/`. The app user needs to be able to write there:
```bash
sudo chown my_webapp:my_webapp /var/www/my_webapp/www/images/backgrounds
@@ -171,11 +171,12 @@ During your next stream, tracks should start appearing on `live.php` within a mi
## 9. Before each broadcast
1. Queue the show from the admin dashboard, or over SSH:
1. Queue the show from the admin dashboard, or over SSH, ideally with its date and start time (Eastern). The main page's sidebar then shows the show, its description and the start time with a UTC conversion:
```bash
sudo -u my_webapp /usr/bin/php8.2 cli/set_show.php # list shows
sudo -u my_webapp /usr/bin/php8.2 cli/set_show.php <slug> # queue one
sudo -u my_webapp /usr/bin/php8.2 cli/set_show.php <slug> 2026-10-30 19:30 # queue one with a start time
```
2. Start streaming. Within a minute, the poller starts the queued broadcast. The live page's heading and background switch to the show, and tracks begin to appear.
+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>
+92 -39
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.';
// 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 {
$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.';
$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']): ?>
+18 -4
View File
@@ -3,6 +3,8 @@
// php cli/set_show.php list shows and the current state
// php cli/set_show.php <slug> queue that show for the next broadcast
// (or assign it to the live one if already streaming)
// php cli/set_show.php <slug> 2026-10-30 19:30
// queue it with an advertised start time (Eastern) for the main page
if (PHP_SAPI !== 'cli') {
http_response_code(403);
@@ -14,6 +16,14 @@ require __DIR__ . '/../includes/broadcasts.php';
$db = getDB();
$slug = $argv[1] ?? null;
$scheduledAt = null;
if (isset($argv[2])) {
$scheduledAt = stationTimeToUtc($argv[2] . ' ' . ($argv[3] ?? ''));
if ($scheduledAt === null) {
fwrite(STDERR, "Start time must be a valid date and time like 2026-10-30 19:30 (" . STATION_TIMEZONE_LABEL . ").\n");
exit(1);
}
}
if ($slug === null) {
echo "Shows:\n";
@@ -21,7 +31,11 @@ if ($slug === null) {
printf(" %-20s %s\n", $show['slug'], $show['name']);
}
$current = currentShow($db);
echo "\nCurrent: " . ($current ? $current['name'] : '(none)') . (liveBroadcast($db) ? ' [live]' : '') . "\n";
echo "\nCurrent: " . ($current ? $current['name'] : '(none)') . (liveBroadcast($db) ? ' [live]' : '');
if ($current && $current['scheduled_at'] && !$current['started_at']) {
echo ', scheduled for ' . utcToStationTime($current['scheduled_at'])->format('Y-m-d H:i') . ' ' . STATION_TIMEZONE_LABEL . " ({$current['scheduled_at']} UTC)";
}
echo "\n";
exit;
}
@@ -33,8 +47,8 @@ if (!$show) {
exit(1);
}
if (setBroadcastShow($db, (int)$show['id']) === 'live') {
echo "Live broadcast is now {$show['name']}.\n";
if (setBroadcastShow($db, (int)$show['id'], $scheduledAt) === 'live') {
echo "Live broadcast is now {$show['name']}." . ($scheduledAt ? ' (Start time ignored.)' : '') . "\n";
} else {
echo "{$show['name']} is queued for the next broadcast.\n";
echo "{$show['name']} is queued for the next broadcast" . ($scheduledAt ? " at {$argv[2]} {$argv[3]} " . STATION_TIMEZONE_LABEL . " ({$scheduledAt} UTC)" : '') . ".\n";
}
+11
View File
@@ -38,6 +38,17 @@ function adminHeader(string $title, ?array $admin = null): void {
#header ul.admin-nav a {
color: #fff;
}
/* Strata doesn't style these input types */
input[type="date"],
input[type="time"] {
background: #f7f7f7;
border-radius: 0.35em;
border: solid 2px transparent;
color: inherit;
height: 2.75em;
padding: 0 0.75em;
width: 100%;
}
.admin-message {
padding: 0.75em 1em;
border-radius: 4px;
+25 -5
View File
@@ -12,11 +12,17 @@ require_once __DIR__ . '/icecast.php';
const BROADCAST_GRACE_MINUTES = 5;
// Tracks are cleared this long after their broadcast ends
const TRACK_RETENTION_MINUTES = 10;
// Show times are entered and displayed in the station's time zone; the database stores UTC
const STATION_TIMEZONE = 'America/New_York';
const STATION_TIMEZONE_LABEL = 'Eastern';
// A broadcast unseen for longer than the grace period is treated as over even before the poller marks it
// ended, so a stopped poller or a long status API outage can't leave a show showing as live.
function liveBroadcast(PDO $db): ?array {
$row = $db->query(
'SELECT * FROM broadcasts
WHERE started_at IS NOT NULL AND ended_at IS NULL
AND last_seen_at >= NOW() - INTERVAL ' . BROADCAST_GRACE_MINUTES . ' MINUTE
ORDER BY started_at DESC LIMIT 1'
)->fetch();
return $row ?: null;
@@ -31,28 +37,42 @@ function pendingBroadcast(PDO $db): ?array {
return $row ?: null;
}
// The show for the live broadcast, or the queued one if nothing is live yet
// The show for the live broadcast, or the queued one if nothing is live yet,
// with the broadcast's scheduled_at and started_at. Stale broadcasts are skipped as in liveBroadcast().
function currentShow(PDO $db): ?array {
$row = $db->query(
'SELECT s.* FROM broadcasts b
'SELECT s.*, b.scheduled_at, b.started_at FROM broadcasts b
JOIN shows s ON s.id = b.show_id
WHERE b.ended_at IS NULL
AND (b.started_at IS NULL OR b.last_seen_at >= NOW() - INTERVAL ' . BROADCAST_GRACE_MINUTES . ' MINUTE)
ORDER BY b.started_at IS NULL, b.started_at DESC, b.created_at DESC
LIMIT 1'
)->fetch();
return $row ?: null;
}
// Converts a 'YYYY-MM-DD HH:MM' station time to a UTC DATETIME string, or null if it isn't a valid time
function stationTimeToUtc(string $local): ?string {
$dt = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $local, new DateTimeZone(STATION_TIMEZONE));
// Reformatting catches overflowing dates (Feb 30) and times skipped by the DST change
if ($dt === false || $dt->format('Y-m-d H:i') !== $local) return null;
return $dt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}
function utcToStationTime(string $utc): DateTimeImmutable {
return (new DateTimeImmutable($utc, new DateTimeZone('UTC')))->setTimezone(new DateTimeZone(STATION_TIMEZONE));
}
// Assigns a show to the live broadcast, or queues it for the next one if nothing is live.
// Returns 'live' or 'queued'.
function setBroadcastShow(PDO $db, int $showId): string {
// $scheduledAt (UTC) is the advertised start time of a queued show. Returns 'live' or 'queued'.
function setBroadcastShow(PDO $db, int $showId, ?string $scheduledAt = null): string {
$live = liveBroadcast($db);
if ($live !== null) {
$db->prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$showId, $live['id']]);
return 'live';
}
clearQueuedShow($db);
$db->prepare('INSERT INTO broadcasts (show_id) VALUES (?)')->execute([$showId]);
$db->prepare('INSERT INTO broadcasts (show_id, scheduled_at) VALUES (?, ?)')->execute([$showId, $scheduledAt]);
return 'queued';
}
+59 -3
View File
@@ -1,3 +1,37 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/broadcasts.php';
// The sidebar falls back to a general note if the database is unavailable
$show = null;
try {
$show = currentShow(getDB());
} catch (Throwable $e) {
}
$icon = '';
$paragraphs = [];
$when = [];
if ($show) {
if ($show['icon_image'] && is_file(__DIR__ . '/images/backgrounds/' . basename($show['icon_image']))) {
$icon = 'images/backgrounds/' . rawurlencode(basename($show['icon_image']));
}
// Blank lines in the description separate paragraphs
if ($show['description']) {
$paragraphs = preg_split('/\n\s*\n/', trim($show['description']));
}
// e.g. "Friday, October 30, 2026" / "7:30pm Eastern" / "(23:30 UTC)", naming the UTC day when it differs
if (!$show['started_at'] && $show['scheduled_at']) {
$local = utcToStationTime($show['scheduled_at']);
$utc = new DateTimeImmutable($show['scheduled_at'], new DateTimeZone('UTC'));
$when = [
$local->format('l, F j, Y'),
$local->format($local->format('i') === '00' ? 'ga' : 'g:ia') . ' ' . STATION_TIMEZONE_LABEL,
'(' . $utc->format('H:i') . ' UTC' . ($utc->format('Y-m-d') !== $local->format('Y-m-d') ? ', ' . $utc->format('l') : '') . ')',
];
}
}
?>
<!DOCTYPE HTML>
<!--
RFE NEW SITE
@@ -71,8 +105,30 @@
<!-- Upcoming & Schedule -->
<section id="one">
<header class="major">
<h3>Upcoming Schedule</h3>
<h3><?= $show && $show['started_at'] ? 'On the Air Now' : 'Upcoming Schedule' ?></h3>
</header>
<!-- The queued (or live) show comes from the database: queue it on the admin dashboard.
The commented-out blocks below are the hand-written history from before that. -->
<?php if ($show): ?>
<div class="show-heading">
<?php if ($icon !== ''): ?>
<span class="image left"><img src="<?= htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') ?>" alt="<?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?> logo" /></span>
<?php endif; ?>
<h3><?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?></h3>
<?php if ($show['started_at']): ?>
<p><strong>Live now</strong></p>
<?php elseif ($when): ?>
<p><strong><?= implode('<br>', array_map(fn($line) => htmlspecialchars($line, ENT_QUOTES, 'UTF-8'), $when)) ?></strong></p>
<?php endif; ?>
</div>
<hr>
<?php foreach ($paragraphs as $paragraph): ?>
<p><?= nl2br(htmlspecialchars($paragraph, ENT_QUOTES, 'UTF-8')) ?></p>
<?php endforeach; ?>
<?php else: ?>
<p>Nothing is scheduled just now. Follow along on <a href="https://masto.hackers.town/@cyberpunklibrarian">Mastodon</a> or <a href="https://bsky.app/profile/cyberpunklibrarian.bsky.social">Bluesky</a> to hear when we're going live next.</p>
<?php endif; ?>
<!-- BEGIN THE SPIN -->
<!--<div class="show-heading">
<span class="image left"><img src="images/the-spin.jpg" alt="The Spin Logo" /></span>
@@ -86,7 +142,7 @@
<!-- END THE SPIN -->
<!-- BEGIN O'MORAIN'S PUB -->
<div class="show-heading">
<!--<div class="show-heading">
<span class="image left"><img src="images/omorains-icon.jpg" alt="O'Morain's Pub Logo" /></span>
<h3>O'Morain's Pub</h3>
<p><strong>Friday, October 30, 2026<br>1pm Eastern<br>(17:00 UTC)</strong></p>
@@ -94,7 +150,7 @@
<hr>
<p>Sit back, have a pint (or two) and listen in on personally curated Celtic folk. Classics, new favourites, and new takes on old songs from all over the world of Celtic music. Cross the Atlantic from Scotland to Nova Scotia, Ireland to Appalachia, and Brittany to Cape Breton &ndash; O’Morain’s Pub has your ticket for fantastic music across the Celtic experience.</p>
<!-- <p>So raise your glass, you’ve found something a little different from the norm. Sláinte!</p> -->
<p>So raise your glass, you’ve found something a little different from the norm. Sláinte!</p> -->
<!-- END O'MORAIN'S PUB -->
<!-- COSMOEUPHORIA --> <!--
+39 -1
View File
@@ -6,19 +6,25 @@ CREATE TABLE IF NOT EXISTS shows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
-- Plain text shown on index.php when the show is queued; blank lines separate paragraphs
description TEXT NULL,
-- Filename in images/backgrounds/ of the square show logo shown next to the description
icon_image VARCHAR(255) NULL,
-- Filename in images/backgrounds/ used as the live.php header background; NULL keeps the default
background_image VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- One row per streaming session.
-- Pending (queued by cli/set_show.php): started_at IS NULL
-- Pending (queued from the dashboard or cli/set_show.php): started_at IS NULL
-- Live: started_at set, ended_at IS NULL
-- Ended: ended_at set
CREATE TABLE IF NOT EXISTS broadcasts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
show_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Advertised start time of a queued show; the broadcast still starts when the stream comes online
scheduled_at DATETIME NULL,
started_at DATETIME NULL,
last_seen_at DATETIME NULL,
ended_at DATETIME NULL,
@@ -27,6 +33,11 @@ CREATE TABLE IF NOT EXISTS broadcasts (
FOREIGN KEY (show_id) REFERENCES shows (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Columns added after the first release, for databases created before them
ALTER TABLE shows ADD COLUMN IF NOT EXISTS description TEXT NULL AFTER name;
ALTER TABLE shows ADD COLUMN IF NOT EXISTS icon_image VARCHAR(255) NULL AFTER description;
ALTER TABLE broadcasts ADD COLUMN IF NOT EXISTS scheduled_at DATETIME NULL AFTER created_at;
-- Tracks are cleared ten minutes after their broadcast ends.
CREATE TABLE IF NOT EXISTS played_tracks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@@ -63,3 +74,30 @@ INSERT IGNORE INTO shows (slug, name) VALUES
('bisexual-surfers', 'Bisexual Surfers From the Twilight of Time'),
('the-bollocks', 'The Bollocks'),
('the-spin', 'The Spin');
-- Starting descriptions, taken from the old hand-written index.php. Only fills in blanks, so edits made
-- on the admin pages survive re-running this file.
UPDATE shows SET description = 'Sit back, have a pint (or two) and listen in on personally curated Celtic folk. Classics, new favourites, and new takes on old songs from all over the world of Celtic music. Cross the Atlantic from Scotland to Nova Scotia, Ireland to Appalachia, and Brittany to Cape Breton – O’Morain’s Pub has your ticket for fantastic music across the Celtic experience.
So raise your glass, you’ve found something a little different from the norm. Sláinte!'
WHERE slug = 'omorains-pub' AND description IS NULL;
UPDATE shows SET description = 'Take a trip without leaving your seat.
Cast your consciousness across the vastness of the cosmos or explore your own inner space. You need nothing more than a set of speakers or your headphones.
Welcome to the worlds of Cosmoeuphoria, where we’ll cross the galactic divides to visit places beyond imagination. Though the magic of space and ambient music, classics and new tracks from the masters of the genres, you’ll never have to worry about flashbacks or passing a drug test.
So close your eyes, but maybe open up your third one. Let’s take a journey.'
WHERE slug = 'cosmoeuphoria' AND description IS NULL;
UPDATE shows SET description = 'Surf''s up, dude!
They came from the darkness of space and time, beautiful surfers with shiny waxed boards to match their shiny waxed bodies. No one knows what they truly want but, for now, we can take solace in the knowledge that they are here to party... and surf. Falling under their spell, we''ll spin surf rock from across the spectrum, horror to rockabilly, instrumental to vocal. We''ll shoot the curve through the pipeline and only god knows what we''ll find on the other side.
Beyond the wild waves lies the tempest of spacetime, and they''ll take anyone they please.'
WHERE slug = 'bisexual-surfers' AND description IS NULL;
UPDATE shows SET description = 'Punk rock bangers from the well-known to the deep cuts you don''t hear every day.'
WHERE slug = 'the-bollocks' AND description IS NULL;
UPDATE shows SET description = 'New (to me) music that I (somewhat) recently found while wandering around the Net or around the airwaves. I''ll drop those new tunes in a playlist and give them a full listen through before adding them to my library. And the name of that playlist?
The Spin. Join me, and let''s discover some new tunes!'
WHERE slug = 'the-spin' AND description IS NULL;