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 ## Key Files
Public pages: 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. - [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. - [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`. - [recently_played.php](recently_played.php) — Returns the current broadcast's track history as table rows for `live.php`.
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, switch the live show, clear the queue. - [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 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:
- [includes/db.php](includes/db.php) — `getDB()` returns a shared PDO connection (session time zone UTC). - [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): 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. 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. - [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password.
Other: 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)): 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/`). - `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. - `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. - `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.
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 ## 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. - 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 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. - 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. - 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. - 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. - 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 ## Conventions
@@ -128,9 +128,11 @@ Server-only setup (one-time, or when the schema changes):
## Updating Show Information ## 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 ## 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 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 ```bash
cd /var/www/my_webapp/www 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 ## 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 ```bash
sudo chown my_webapp:my_webapp /var/www/my_webapp/www/images/backgrounds 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 ## 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 ```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 # 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
``` ```
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.
+32 -7
View File
@@ -6,6 +6,14 @@ require __DIR__ . '/../includes/broadcasts.php';
$admin = requireAdmin(); $admin = requireAdmin();
$db = getDB(); $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') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf(); verifyCsrf();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@@ -14,12 +22,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = $db->prepare('SELECT id, name FROM shows WHERE id = ?'); $stmt = $db->prepare('SELECT id, name FROM shows WHERE id = ?');
$stmt->execute([(int)($_POST['show_id'] ?? 0)]); $stmt->execute([(int)($_POST['show_id'] ?? 0)]);
$show = $stmt->fetch(); $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) { if (!$show) {
flash('Please choose a show.', 'error'); 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']}."); flash("The live broadcast is now {$show['name']}.");
} else { } 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') { } elseif ($action === 'clear_queue') {
clearQueuedShow($db); clearQueuedShow($db);
@@ -33,6 +47,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$live = liveBroadcast($db); $live = liveBroadcast($db);
$current = currentShow($db); $current = currentShow($db);
$shows = $db->query('SELECT id, name FROM shows ORDER BY name')->fetchAll(); $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); adminHeader('Dashboard', $admin);
?> ?>
@@ -41,7 +56,7 @@ adminHeader('Dashboard', $admin);
<?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']) : ' (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. Offline. <strong><?= e($current['name']) ?></strong> 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; ?>
@@ -50,12 +65,12 @@ 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 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"> <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="col-8 col-12-small"> <div class="<?= $live ? 'col-8' : 'col-12' ?> col-12-small">
<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): ?>
@@ -63,8 +78,18 @@ adminHeader('Dashboard', $admin);
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="col-4 col-12-small"> <?php if (!$live): ?>
<input type="submit" class="primary fit" value="<?= $live ? 'Switch show' : 'Queue show' ?>" /> <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>
</div> </div>
</form> </form>
+92 -39
View File
@@ -5,26 +5,50 @@ require __DIR__ . '/../includes/admin_layout.php';
$admin = requireAdmin(); $admin = requireAdmin();
$db = getDB(); $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_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 // 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 { function slugify(string $name): string {
$slug = strtolower(str_replace(["'", '’'], '', $name)); $slug = strtolower(str_replace(["'", '’'], '', $name));
return trim(preg_replace('/[^a-z0-9]+/', '-', $slug), '-'); return trim(preg_replace('/[^a-z0-9]+/', '-', $slug), '-');
} }
function deleteBackground(?string $filename): void { function deleteUpload(?string $filename): void {
if ($filename && is_file(BACKGROUND_DIR . basename($filename))) { if ($filename && is_file(UPLOAD_DIR . basename($filename))) {
unlink(BACKGROUND_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); $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) { 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]); $stmt->execute([$id]);
$show = $stmt->fetch(); $show = $stmt->fetch();
if (!$show) { if (!$show) {
@@ -38,13 +62,14 @@ $errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// PHP discards the whole form when a request exceeds post_max_size, so catch that before the CSRF check // 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) { 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 { } else {
verifyCsrf(); verifyCsrf();
if (($_POST['action'] ?? '') === 'delete' && $id) { if (($_POST['action'] ?? '') === 'delete' && $id) {
$db->prepare('DELETE FROM shows WHERE id = ?')->execute([$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']}."); flash("Deleted {$show['name']}.");
header('Location: shows.php'); header('Location: shows.php');
exit; exit;
@@ -52,6 +77,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$show['name'] = trim((string)($_POST['name'] ?? '')); $show['name'] = trim((string)($_POST['name'] ?? ''));
$show['slug'] = trim((string)($_POST['slug'] ?? '')) ?: slugify($show['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) { if ($show['name'] === '' || mb_strlen($show['name']) > 255) {
$errors[] = 'Name is required (up to 255 characters).'; $errors[] = 'Name is required (up to 255 characters).';
@@ -63,46 +90,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$show['slug'], $id]); $stmt->execute([$show['slug'], $id]);
if ($stmt->fetchColumn() > 0) $errors[] = "Another show already uses the slug '{$show['slug']}'."; 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; // column => [form field, label, size limit, filename suffix after the slug]
$uploadExt = null; $images = [
if ($upload && $upload['error'] !== UPLOAD_ERR_NO_FILE) { 'background_image' => ['background', 'background image', MAX_BACKGROUND_BYTES, ''],
if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > MAX_BACKGROUND_BYTES) { 'icon_image' => ['icon', 'icon', MAX_ICON_BYTES, '-icon'],
$errors[] = 'Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.'; ];
} elseif ($upload['error'] !== UPLOAD_ERR_OK) { $uploadExts = [];
$errors[] = 'The background image failed to upload. Please try again.'; 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 { } else {
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']); $errors[] = "The {$label} could not be saved. Check that the web server can write to images/backgrounds/.";
$uploadExt = BACKGROUND_TYPES[$mime] ?? null; }
if ($uploadExt === null || getimagesize($upload['tmp_name']) === false) { } elseif (!empty($_POST["remove_{$field}"])) {
$errors[] = 'Background images must be JPEG, PNG or WebP.'; $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) { if (!$errors) {
$oldBackground = $show['background_image']; $description = $show['description'] === '' ? null : $show['description'];
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) {
if ($id) { if ($id) {
$db->prepare('UPDATE shows SET name = ?, slug = ?, background_image = ? WHERE id = ?') $db->prepare('UPDATE shows SET name = ?, slug = ?, description = ?, icon_image = ?, background_image = ? WHERE id = ?')
->execute([$show['name'], $show['slug'], $show['background_image'], $id]); ->execute([$show['name'], $show['slug'], $description, $show['icon_image'], $show['background_image'], $id]);
} else { } else {
$db->prepare('INSERT INTO shows (name, slug, background_image) VALUES (?, ?, ?)') $db->prepare('INSERT INTO shows (name, slug, description, icon_image, background_image) VALUES (?, ?, ?, ?, ?)')
->execute([$show['name'], $show['slug'], $show['background_image']]); ->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']}."); flash("Saved {$show['name']}.");
header('Location: shows.php'); 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" /> <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> <p><small>Short name used by <code>cli/set_show.php</code>. Lowercase letters, numbers and dashes.</small></p>
</div> </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"> <div class="col-12">
<label for="background">Background image</label> <label for="background">Background image</label>
<?php if ($show['background_image']): ?> <?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 list shows and the current state
// php cli/set_show.php <slug> queue that show for the next broadcast // php cli/set_show.php <slug> queue that show for the next broadcast
// (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
// queue it with an advertised start time (Eastern) for the main page
if (PHP_SAPI !== 'cli') { if (PHP_SAPI !== 'cli') {
http_response_code(403); http_response_code(403);
@@ -14,6 +16,14 @@ require __DIR__ . '/../includes/broadcasts.php';
$db = getDB(); $db = getDB();
$slug = $argv[1] ?? null; $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) { if ($slug === null) {
echo "Shows:\n"; echo "Shows:\n";
@@ -21,7 +31,11 @@ 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]' : '') . "\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; exit;
} }
@@ -33,8 +47,8 @@ if (!$show) {
exit(1); exit(1);
} }
if (setBroadcastShow($db, (int)$show['id']) === 'live') { if (setBroadcastShow($db, (int)$show['id'], $scheduledAt) === 'live') {
echo "Live broadcast is now {$show['name']}.\n"; echo "Live broadcast is now {$show['name']}." . ($scheduledAt ? ' (Start time ignored.)' : '') . "\n";
} else { } 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 { #header ul.admin-nav a {
color: #fff; 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 { .admin-message {
padding: 0.75em 1em; padding: 0.75em 1em;
border-radius: 4px; border-radius: 4px;
+25 -5
View File
@@ -12,11 +12,17 @@ require_once __DIR__ . '/icecast.php';
const BROADCAST_GRACE_MINUTES = 5; const BROADCAST_GRACE_MINUTES = 5;
// Tracks are cleared this long after their broadcast ends // Tracks are cleared this long after their broadcast ends
const TRACK_RETENTION_MINUTES = 10; 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 { function liveBroadcast(PDO $db): ?array {
$row = $db->query( $row = $db->query(
'SELECT * FROM broadcasts 'SELECT * FROM broadcasts
WHERE started_at IS NOT NULL AND ended_at IS NULL 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' ORDER BY started_at DESC LIMIT 1'
)->fetch(); )->fetch();
return $row ?: null; return $row ?: null;
@@ -31,28 +37,42 @@ function pendingBroadcast(PDO $db): ?array {
return $row ?: null; 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 { function currentShow(PDO $db): ?array {
$row = $db->query( $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 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)
ORDER BY b.started_at IS NULL, b.started_at DESC, b.created_at DESC ORDER BY b.started_at IS NULL, b.started_at DESC, b.created_at DESC
LIMIT 1' LIMIT 1'
)->fetch(); )->fetch();
return $row ?: null; 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. // Assigns a show to the live broadcast, or queues it for the next one if nothing is live.
// Returns 'live' or 'queued'. // $scheduledAt (UTC) is the advertised start time of a queued show. Returns 'live' or 'queued'.
function setBroadcastShow(PDO $db, int $showId): string { function setBroadcastShow(PDO $db, int $showId, ?string $scheduledAt = null): string {
$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 = ? WHERE id = ?')->execute([$showId, $live['id']]);
return 'live'; return 'live';
} }
clearQueuedShow($db); 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'; 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> <!DOCTYPE HTML>
<!-- <!--
RFE NEW SITE RFE NEW SITE
@@ -71,8 +105,30 @@
<!-- Upcoming & Schedule --> <!-- Upcoming & Schedule -->
<section id="one"> <section id="one">
<header class="major"> <header class="major">
<h3>Upcoming Schedule</h3> <h3><?= $show && $show['started_at'] ? 'On the Air Now' : 'Upcoming Schedule' ?></h3>
</header> </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 --> <!-- BEGIN THE SPIN -->
<!--<div class="show-heading"> <!--<div class="show-heading">
<span class="image left"><img src="images/the-spin.jpg" alt="The Spin Logo" /></span> <span class="image left"><img src="images/the-spin.jpg" alt="The Spin Logo" /></span>
@@ -86,7 +142,7 @@
<!-- END THE SPIN --> <!-- END THE SPIN -->
<!-- BEGIN O'MORAIN'S PUB --> <!-- 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> <span class="image left"><img src="images/omorains-icon.jpg" alt="O'Morain's Pub Logo" /></span>
<h3>O'Morain's Pub</h3> <h3>O'Morain's Pub</h3>
<p><strong>Friday, October 30, 2026<br>1pm Eastern<br>(17:00 UTC)</strong></p> <p><strong>Friday, October 30, 2026<br>1pm Eastern<br>(17:00 UTC)</strong></p>
@@ -94,7 +150,7 @@
<hr> <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>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 --> <!-- END O'MORAIN'S PUB -->
<!-- COSMOEUPHORIA --> <!-- <!-- COSMOEUPHORIA --> <!--
+39 -1
View File
@@ -6,19 +6,25 @@ CREATE TABLE IF NOT EXISTS shows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE, slug VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, 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 -- Filename in images/backgrounds/ used as the live.php header background; NULL keeps the default
background_image VARCHAR(255) NULL, background_image VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- One row per streaming session. -- 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 -- Live: started_at set, ended_at IS NULL
-- Ended: ended_at set -- Ended: ended_at set
CREATE TABLE IF NOT EXISTS broadcasts ( CREATE TABLE IF NOT EXISTS broadcasts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
show_id INT UNSIGNED NULL, show_id INT UNSIGNED NULL,
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
scheduled_at DATETIME 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,
@@ -27,6 +33,11 @@ CREATE TABLE IF NOT EXISTS broadcasts (
FOREIGN KEY (show_id) REFERENCES shows (id) ON DELETE SET NULL FOREIGN KEY (show_id) REFERENCES shows (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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. -- Tracks are cleared ten minutes after their broadcast ends.
CREATE TABLE IF NOT EXISTS played_tracks ( CREATE TABLE IF NOT EXISTS played_tracks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 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'), ('bisexual-surfers', 'Bisexual Surfers From the Twilight of Time'),
('the-bollocks', 'The Bollocks'), ('the-bollocks', 'The Bollocks'),
('the-spin', 'The Spin'); ('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;