Add optional episode titles to queued and live broadcasts

Store an episode_title on each broadcast, settable from the dashboard
or with set_show.php --title=, and show it under the show name on
index.php and under the heading on live.php.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel MesserandClaude Opus 5.5 committed 2026-10-30 16:11:22 -05:00
1 parent 9bc9af87c7
commit 170ae6eed0
9 files changed
+67 -25

No files matched your search

+4 -4
View File
@@ -27,7 +27,7 @@ Public pages:
Admin backend ([admin/](admin/)): Admin backend ([admin/](admin/)):
- [admin/login.php](admin/login.php), [admin/logout.php](admin/logout.php) — Username/password login. - [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 (with an optional date and Eastern start time), switch the live show, clear the queue. - [admin/index.php](admin/index.php) — Dashboard: broadcast status, queue the next show (with an optional episode title, date and Eastern start time), switch the live show or its episode title, 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. - [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: Shared includes:
@@ -39,7 +39,7 @@ Shared includes:
Command-line tools (run over SSH from the site root): 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/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, optionally with a start time (`php cli/set_show.php <slug> 2026-10-30 19:30`). 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 and episode title (`php cli/set_show.php <slug> 2026-10-30 19:30 --title="Ethereal and Enya"`). Same behaviour as the dashboard control.
- [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password. - [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password.
Other: Other:
@@ -60,7 +60,7 @@ 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)): 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`, a plain-text `description` (blank lines separate paragraphs) and optional `icon_image` and `background_image` (filenames in `images/backgrounds/`). - `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. - `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. `episode_title` is an optional per-episode title (e.g. "Ethereal and Enya") shown under the show name on `index.php` and under the heading on `live.php`.
- `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept. - `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept.
- `admins` — admin accounts (username + password hash). - `admins` — admin accounts (username + password hash).
- `login_attempts` — failed logins by IP, used for throttling. - `login_attempts` — failed logins by IP, used for throttling.
@@ -128,7 +128,7 @@ Server-only setup (one-time, or when the schema changes):
## Updating Show Information ## Updating Show Information
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 (with the icon and a "Broadcasting Live" link pointing to `live.php`), and falls back to a short "nothing scheduled" note once nothing is queued. Edit descriptions and icons on the show's admin page. 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, Eastern start time and optional episode title; the sidebar then shows its icon, name, episode title, start time (with the UTC conversion) and description, switches to "On the Air Now" while live (with the icon and a "Broadcasting Live" link pointing to `live.php`), and falls back to a short "nothing scheduled" note once nothing is queued. Edit descriptions and icons on the show's admin page.
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. 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.
+1
View File
@@ -177,6 +177,7 @@ During your next stream, tracks should start appearing on `live.php` within a mi
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 # 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> # 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 sudo -u my_webapp /usr/bin/php8.2 cli/set_show.php <slug> 2026-10-30 19:30 # queue one with a start time
sudo -u my_webapp /usr/bin/php8.2 cli/set_show.php <slug> 2026-10-30 19:30 --title="Ethereal and Enya" # ...and an episode title
``` ```
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. 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.
+14 -8
View File
@@ -25,14 +25,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$date = trim((string)($_POST['date'] ?? '')); $date = trim((string)($_POST['date'] ?? ''));
$time = substr(trim((string)($_POST['time'] ?? '')), 0, 5); $time = substr(trim((string)($_POST['time'] ?? '')), 0, 5);
$scheduledAt = $date === '' && $time === '' ? null : stationTimeToUtc("$date $time"); $scheduledAt = $date === '' && $time === '' ? null : stationTimeToUtc("$date $time");
$episodeTitle = trim((string)($_POST['episode_title'] ?? ''));
$label = $show['name'] . ($episodeTitle !== '' ? " ({$episodeTitle})" : '');
if (!$show) { if (!$show) {
flash('Please choose a show.', 'error'); flash('Please choose a show.', 'error');
} elseif (($date !== '' || $time !== '') && $scheduledAt === null) { } elseif (($date !== '' || $time !== '') && $scheduledAt === null) {
flash('Please enter both a valid date and time, or leave both blank.', 'error'); flash('Please enter both a valid date and time, or leave both blank.', 'error');
} elseif (setBroadcastShow($db, (int)$show['id'], $scheduledAt) === 'live') { } elseif (setBroadcastShow($db, (int)$show['id'], $scheduledAt, $episodeTitle) === 'live') {
flash("The live broadcast is now {$show['name']}."); flash("The live broadcast is now {$label}.");
} else { } else {
flash("{$show['name']} is queued" . ($scheduledAt ? ' for ' . describeScheduledAt($scheduledAt) : '') flash("{$label} is queued" . ($scheduledAt ? ' for ' . describeScheduledAt($scheduledAt) : '')
. '. It will go live as soon as the stream starts.'); . '. It will go live as soon as the stream starts.');
} }
} elseif ($action === 'clear_queue') { } elseif ($action === 'clear_queue') {
@@ -54,9 +56,9 @@ adminHeader('Dashboard', $admin);
<h3>Broadcast status</h3> <h3>Broadcast status</h3>
<p> <p>
<?php if ($live): ?> <?php if ($live): ?>
<strong>Live now</strong><?= $current ? ': ' . e($current['name']) : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC. <strong>Live now</strong><?= $current ? ': ' . e($current['name']) . ($current['episode_title'] ? ' — “' . e($current['episode_title']) . '”' : '') : ' (no show set)' ?>, since <?= e($live['started_at']) ?> UTC.
<?php elseif ($current): ?> <?php elseif ($current): ?>
Offline. <strong><?= e($current['name']) ?></strong> is queued for the next broadcast<?= $current['scheduled_at'] ? ', scheduled for ' . e(describeScheduledAt($current['scheduled_at'])) : '' ?>. Offline. <strong><?= e($current['name']) ?></strong><?= $current['episode_title'] ? ' — “' . e($current['episode_title']) . '”' : '' ?> is queued for the next broadcast<?= $current['scheduled_at'] ? ', scheduled for ' . e(describeScheduledAt($current['scheduled_at'])) : '' ?>.
<?php else: ?> <?php else: ?>
Offline. No show is queued. Offline. No show is queued.
<?php endif; ?> <?php endif; ?>
@@ -64,13 +66,13 @@ adminHeader('Dashboard', $admin);
<h3><?= $live ? 'Change the live show' : 'Queue the next show' ?></h3> <h3><?= $live ? 'Change the live show' : 'Queue the next show' ?></h3>
<p><?= $live <p><?= $live
? 'Switch which show the live page and recently played list belong to.' ? 'Switch which show, and episode title, 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. The show, and its start time if you enter one, appear on the main page until then.' ?></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 episode title and start time if you enter them, appear on the main page until then.' ?></p>
<form method="post" action="index.php"> <form method="post" action="index.php">
<?= csrfField() ?> <?= csrfField() ?>
<input type="hidden" name="action" value="set_show" /> <input type="hidden" name="action" value="set_show" />
<div class="row gtr-uniform gtr-50"> <div class="row gtr-uniform gtr-50">
<div class="<?= $live ? 'col-8' : 'col-12' ?> col-12-small"> <div class="col-12">
<select name="show_id" aria-label="Show" required> <select name="show_id" aria-label="Show" required>
<option value="">Choose a show…</option> <option value="">Choose a show…</option>
<?php foreach ($shows as $show): ?> <?php foreach ($shows as $show): ?>
@@ -78,6 +80,10 @@ adminHeader('Dashboard', $admin);
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="<?= $live ? 'col-8' : 'col-12' ?> col-12-small">
<label for="episode_title">Episode title (optional)</label>
<input type="text" name="episode_title" id="episode_title" maxlength="255" placeholder="e.g. Ethereal and Enya" value="<?= $current && $current['episode_title'] ? e($current['episode_title']) : '' ?>" />
</div>
<?php if (!$live): ?> <?php if (!$live): ?>
<div class="col-6 col-12-xsmall"> <div class="col-6 col-12-xsmall">
<label for="date">Date (optional)</label> <label for="date">Date (optional)</label>
+4
View File
@@ -2656,6 +2656,10 @@ input, select, textarea {
margin-bottom: 0; margin-bottom: 0;
} }
#header #one .show-heading .episode-title {
margin-bottom: 0.5em;
}
/* Footer */ /* Footer */
#footer .icons { #footer .icons {
+21 -7
View File
@@ -5,6 +5,8 @@
// (or assign it to the live one if already streaming) // (or assign it to the live one if already streaming)
// php cli/set_show.php <slug> 2026-10-30 19:30 // php cli/set_show.php <slug> 2026-10-30 19:30
// queue it with an advertised start time (Eastern) for the main page // queue it with an advertised start time (Eastern) for the main page
// php cli/set_show.php <slug> 2026-10-30 19:30 --title="Ethereal and Enya"
// also give the episode a title (works with or without a start time)
if (PHP_SAPI !== 'cli') { if (PHP_SAPI !== 'cli') {
http_response_code(403); http_response_code(403);
@@ -15,10 +17,20 @@ require __DIR__ . '/../includes/db.php';
require __DIR__ . '/../includes/broadcasts.php'; require __DIR__ . '/../includes/broadcasts.php';
$db = getDB(); $db = getDB();
$slug = $argv[1] ?? null; // Pull out --title=... so the remaining arguments stay positional
$episodeTitle = null;
$args = [];
foreach (array_slice($argv, 1) as $arg) {
if (str_starts_with($arg, '--title=')) {
$episodeTitle = substr($arg, 8);
} else {
$args[] = $arg;
}
}
$slug = $args[0] ?? null;
$scheduledAt = null; $scheduledAt = null;
if (isset($argv[2])) { if (isset($args[1])) {
$scheduledAt = stationTimeToUtc($argv[2] . ' ' . ($argv[3] ?? '')); $scheduledAt = stationTimeToUtc($args[1] . ' ' . ($args[2] ?? ''));
if ($scheduledAt === null) { if ($scheduledAt === null) {
fwrite(STDERR, "Start time must be a valid date and time like 2026-10-30 19:30 (" . STATION_TIMEZONE_LABEL . ").\n"); fwrite(STDERR, "Start time must be a valid date and time like 2026-10-30 19:30 (" . STATION_TIMEZONE_LABEL . ").\n");
exit(1); exit(1);
@@ -31,7 +43,8 @@ if ($slug === null) {
printf(" %-20s %s\n", $show['slug'], $show['name']); printf(" %-20s %s\n", $show['slug'], $show['name']);
} }
$current = currentShow($db); $current = currentShow($db);
echo "\nCurrent: " . ($current ? $current['name'] : '(none)') . (liveBroadcast($db) ? ' [live]' : ''); echo "\nCurrent: " . ($current ? $current['name'] : '(none)')
. ($current && $current['episode_title'] ? " \"{$current['episode_title']}\"" : '') . (liveBroadcast($db) ? ' [live]' : '');
if ($current && $current['scheduled_at'] && !$current['started_at']) { 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 ', scheduled for ' . utcToStationTime($current['scheduled_at'])->format('Y-m-d H:i') . ' ' . STATION_TIMEZONE_LABEL . " ({$current['scheduled_at']} UTC)";
} }
@@ -47,8 +60,9 @@ if (!$show) {
exit(1); exit(1);
} }
if (setBroadcastShow($db, (int)$show['id'], $scheduledAt) === 'live') { $label = $show['name'] . (trim((string)$episodeTitle) !== '' ? ' "' . trim($episodeTitle) . '"' : '');
echo "Live broadcast is now {$show['name']}." . ($scheduledAt ? ' (Start time ignored.)' : '') . "\n"; if (setBroadcastShow($db, (int)$show['id'], $scheduledAt, $episodeTitle) === 'live') {
echo "Live broadcast is now {$label}." . ($scheduledAt ? ' (Start time ignored.)' : '') . "\n";
} else { } else {
echo "{$show['name']} is queued for the next broadcast" . ($scheduledAt ? " at {$argv[2]} {$argv[3]} " . STATION_TIMEZONE_LABEL . " ({$scheduledAt} UTC)" : '') . ".\n"; echo "{$label} is queued for the next broadcast" . ($scheduledAt ? " at {$args[1]} {$args[2]} " . STATION_TIMEZONE_LABEL . " ({$scheduledAt} UTC)" : '') . ".\n";
} }
+9 -6
View File
@@ -38,10 +38,10 @@ function pendingBroadcast(PDO $db): ?array {
} }
// 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(). // with the broadcast's scheduled_at, episode_title and started_at. Stale broadcasts are skipped as in liveBroadcast().
function currentShow(PDO $db): ?array { function currentShow(PDO $db): ?array {
$row = $db->query( $row = $db->query(
'SELECT s.*, b.scheduled_at, b.started_at FROM broadcasts b 'SELECT s.*, b.scheduled_at, b.episode_title, b.started_at FROM broadcasts b
JOIN shows s ON s.id = b.show_id JOIN shows s ON s.id = b.show_id
WHERE b.ended_at IS NULL WHERE b.ended_at IS NULL
AND (b.started_at IS NULL OR b.last_seen_at >= NOW() - INTERVAL ' . BROADCAST_GRACE_MINUTES . ' MINUTE) AND (b.started_at IS NULL OR b.last_seen_at >= NOW() - INTERVAL ' . BROADCAST_GRACE_MINUTES . ' MINUTE)
@@ -64,15 +64,18 @@ function utcToStationTime(string $utc): DateTimeImmutable {
} }
// Assigns a show to the live broadcast, or queues it for the next one if nothing is live. // Assigns a show to the live broadcast, or queues it for the next one if nothing is live.
// $scheduledAt (UTC) is the advertised start time of a queued show. Returns 'live' or 'queued'. // $scheduledAt (UTC) is the advertised start time of a queued show; $episodeTitle is optional and replaces
function setBroadcastShow(PDO $db, int $showId, ?string $scheduledAt = null): string { // any title already set (blank clears it). Returns 'live' or 'queued'.
function setBroadcastShow(PDO $db, int $showId, ?string $scheduledAt = null, ?string $episodeTitle = null): string {
$episodeTitle = trim((string)$episodeTitle);
$episodeTitle = $episodeTitle === '' ? null : mb_substr($episodeTitle, 0, 255);
$live = liveBroadcast($db); $live = liveBroadcast($db);
if ($live !== null) { if ($live !== null) {
$db->prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$showId, $live['id']]); $db->prepare('UPDATE broadcasts SET show_id = ?, episode_title = ? WHERE id = ?')->execute([$showId, $episodeTitle, $live['id']]);
return 'live'; return 'live';
} }
clearQueuedShow($db); clearQueuedShow($db);
$db->prepare('INSERT INTO broadcasts (show_id, scheduled_at) VALUES (?, ?)')->execute([$showId, $scheduledAt]); $db->prepare('INSERT INTO broadcasts (show_id, scheduled_at, episode_title) VALUES (?, ?, ?)')->execute([$showId, $scheduledAt, $episodeTitle]);
return 'queued'; return 'queued';
} }
+3
View File
@@ -119,6 +119,9 @@ if ($show) {
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<h3><?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?></h3> <h3><?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?></h3>
<?php if ($show['episode_title']): ?>
<p class="episode-title"><em><?= htmlspecialchars($show['episode_title'], ENT_QUOTES, 'UTF-8') ?></em></p>
<?php endif; ?>
<?php if ($show['started_at']): ?> <?php if ($show['started_at']): ?>
<p><strong><a href="live.php">Broadcasting Live</a></strong></p> <p><strong><a href="live.php">Broadcasting Live</a></strong></p>
<?php elseif ($when): ?> <?php elseif ($when): ?>
+8
View File
@@ -70,6 +70,11 @@ if ($show && $show['background_image'] && is_file(__DIR__ . '/images/backgrounds
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
} }
.episode-title {
font-size: 1.25em;
font-style: italic;
margin-top: -0.75em;
}
#recently-played td { #recently-played td {
vertical-align: middle; vertical-align: middle;
} }
@@ -159,6 +164,9 @@ if ($show && $show['background_image'] && is_file(__DIR__ . '/images/backgrounds
<section id="one"> <section id="one">
<header class="major"> <header class="major">
<h2>Recently Played<?php if ($show): ?> on <?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?></h2> <h2>Recently Played<?php if ($show): ?> on <?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?></h2>
<?php if ($show && $show['episode_title']): ?>
<p class="episode-title"><?= htmlspecialchars($show['episode_title'], ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
</header> </header>
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
+3
View File
@@ -25,6 +25,8 @@ CREATE TABLE IF NOT EXISTS broadcasts (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Advertised start time of a queued show; the broadcast still starts when the stream comes online -- Advertised start time of a queued show; the broadcast still starts when the stream comes online
scheduled_at DATETIME NULL, scheduled_at DATETIME NULL,
-- Optional title for this episode, e.g. "Ethereal and Enya", shown with the show name
episode_title VARCHAR(255) NULL,
started_at DATETIME NULL, started_at DATETIME NULL,
last_seen_at DATETIME NULL, last_seen_at DATETIME NULL,
ended_at DATETIME NULL, ended_at DATETIME NULL,
@@ -37,6 +39,7 @@ CREATE TABLE IF NOT EXISTS broadcasts (
ALTER TABLE shows ADD COLUMN IF NOT EXISTS description TEXT NULL AFTER name; 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 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; ALTER TABLE broadcasts ADD COLUMN IF NOT EXISTS scheduled_at DATETIME NULL AFTER created_at;
ALTER TABLE broadcasts ADD COLUMN IF NOT EXISTS episode_title VARCHAR(255) NULL AFTER scheduled_at;
-- Tracks are cleared ten minutes after their broadcast ends. -- Tracks are cleared ten minutes after their broadcast ends.
CREATE TABLE IF NOT EXISTS played_tracks ( CREATE TABLE IF NOT EXISTS played_tracks (