diff --git a/CLAUDE.md b/CLAUDE.md index e6ff963..092c8cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is - `broadcasts` — one row per streaming session: pending (queued by `cli/set_show.php`), live, or ended. - `played_tracks` — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept. -`cli/poll_icecast.php` runs from cron every minute and polls Icecast four times, 15 seconds apart. A broadcast starts when the stream comes online and ends after five minutes unseen (so brief drop-outs don't split a show). Before going live, run `php cli/set_show.php ` to choose the show. +`cli/poll_icecast.php` runs from cron every minute and polls Icecast four times, 15 seconds apart. A broadcast starts when the stream comes online and ends after five minutes unseen (so brief drop-outs don't split a show). Before going live, choose the show on the admin dashboard (or run `php cli/set_show.php `). Shows and their background images are managed at `admin/shows.php`; uploads are saved to `images/backgrounds/` under generated names, so the web server needs write access to that directory. ## Admin Authentication diff --git a/admin/index.php b/admin/index.php index 469713b..05274ad 100644 --- a/admin/index.php +++ b/admin/index.php @@ -6,22 +6,75 @@ require __DIR__ . '/../includes/broadcasts.php'; $admin = requireAdmin(); $db = getDB(); +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'set_show') { + $stmt = $db->prepare('SELECT id, name FROM shows WHERE id = ?'); + $stmt->execute([(int)($_POST['show_id'] ?? 0)]); + $show = $stmt->fetch(); + if (!$show) { + flash('Please choose a show.', 'error'); + } elseif (setBroadcastShow($db, (int)$show['id']) === '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."); + } + } elseif ($action === 'clear_queue') { + clearQueuedShow($db); + flash('The queued show has been cleared.'); + } + + header('Location: index.php'); + exit; +} + $live = liveBroadcast($db); -$show = currentShow($db); +$current = currentShow($db); +$shows = $db->query('SELECT id, name FROM shows ORDER BY name')->fetchAll(); adminHeader('Dashboard', $admin); ?> -

Logged in as .

-

Broadcast status

- Live now' . e($show['name']) . '' : ' (no show set)' ?>, since UTC. - - Offline. is queued for the next broadcast. + Live now, since UTC. + + Offline. is queued for the next broadcast. Offline. No show is queued.

+ +

+

+
+ + +
+
+ +
+
+ +
+
+
+ + +
+ + + +
+ '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)); + } +} + +$id = (int)($_GET['id'] ?? 0); +$show = ['id' => 0, 'name' => '', 'slug' => '', 'background_image' => null]; +if ($id) { + $stmt = $db->prepare('SELECT id, name, slug, background_image FROM shows WHERE id = ?'); + $stmt->execute([$id]); + $show = $stmt->fetch(); + if (!$show) { + http_response_code(404); + exit('Show not found.'); + } +} + +$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.'; + } else { + verifyCsrf(); + + if (($_POST['action'] ?? '') === 'delete' && $id) { + $db->prepare('DELETE FROM shows WHERE id = ?')->execute([$id]); + deleteBackground($show['background_image']); + flash("Deleted {$show['name']}."); + header('Location: shows.php'); + exit; + } + + $show['name'] = trim((string)($_POST['name'] ?? '')); + $show['slug'] = trim((string)($_POST['slug'] ?? '')) ?: slugify($show['name']); + + if ($show['name'] === '' || mb_strlen($show['name']) > 255) { + $errors[] = 'Name is required (up to 255 characters).'; + } + if (!preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $show['slug']) || strlen($show['slug']) > 64) { + $errors[] = 'Slug may only contain lowercase letters, numbers and single dashes (up to 64 characters).'; + } else { + $stmt = $db->prepare('SELECT COUNT(*) FROM shows WHERE slug = ? AND id != ?'); + $stmt->execute([$show['slug'], $id]); + if ($stmt->fetchColumn() > 0) $errors[] = "Another show already uses the slug '{$show['slug']}'."; + } + + $upload = $_FILES['background'] ?? null; + $uploadExt = null; + if ($upload && $upload['error'] !== UPLOAD_ERR_NO_FILE) { + if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > MAX_BACKGROUND_BYTES) { + $errors[] = 'Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB.'; + } elseif ($upload['error'] !== UPLOAD_ERR_OK) { + $errors[] = 'The background image failed to upload. Please try again.'; + } else { + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']); + $uploadExt = BACKGROUND_TYPES[$mime] ?? null; + if ($uploadExt === null || getimagesize($upload['tmp_name']) === false) { + $errors[] = 'Background images must be JPEG, PNG or WebP.'; + } + } + } + + 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) { + if ($id) { + $db->prepare('UPDATE shows SET name = ?, slug = ?, background_image = ? WHERE id = ?') + ->execute([$show['name'], $show['slug'], $show['background_image'], $id]); + } else { + $db->prepare('INSERT INTO shows (name, slug, background_image) VALUES (?, ?, ?)') + ->execute([$show['name'], $show['slug'], $show['background_image']]); + } + if ($oldBackground !== $show['background_image']) deleteBackground($oldBackground); + + flash("Saved {$show['name']}."); + header('Location: shows.php'); + exit; + } + } +} + +adminHeader($id ? 'Edit Show' : 'Add a Show', $admin); +?> + +

+ +
+ + +
+
+ + +
+
+ + +

Short name used by cli/set_show.php. Lowercase letters, numbers and dashes.

+
+
+ + +

Current background

+ + +

JPEG, PNG or WebP, up to MB. Shown behind the player on the live page during this show.

+ + + + +
+
+ +
+
+
+ + +
+
+ + + +
+ +query('SELECT id, slug, name, background_image FROM shows ORDER BY name')->fetchAll(); + +adminHeader('Shows', $admin); +?> + + +
+ + + + + + + + + + + + + + + + + + + + + + +
BackgroundNameSlug
+ + + + Default + + Edit
No shows yet.
+
+prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$show['id'], $live['id']]); +if (setBroadcastShow($db, (int)$show['id']) === 'live') { echo "Live broadcast is now {$show['name']}.\n"; } else { - $db->exec('DELETE FROM broadcasts WHERE started_at IS NULL'); - $db->prepare('INSERT INTO broadcasts (show_id) VALUES (?)')->execute([$show['id']]); echo "{$show['name']} is queued for the next broadcast.\n"; } diff --git a/includes/admin_layout.php b/includes/admin_layout.php index a59cf2b..7a42d55 100644 --- a/includes/admin_layout.php +++ b/includes/admin_layout.php @@ -10,6 +10,12 @@ function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } +// One-time status message shown at the top of the next admin page (for post-redirect-get) +function flash(string $message, string $type = 'success'): void { + startAdminSession(); + $_SESSION['flash'] = ['message' => $message, 'type' => $type]; +} + function adminHeader(string $title, ?array $admin = null): void { ?> @@ -56,6 +62,7 @@ function adminHeader(string $title, ?array $admin = null): void {
@@ -72,6 +79,10 @@ function adminHeader(string $title, ?array $admin = null): void {

' . e($_SESSION['flash']['message']) . "

\n"; + unset($_SESSION['flash']); + } } function adminFooter(): void { diff --git a/includes/broadcasts.php b/includes/broadcasts.php index db5abfa..05a2c37 100644 --- a/includes/broadcasts.php +++ b/includes/broadcasts.php @@ -43,6 +43,23 @@ function currentShow(PDO $db): ?array { return $row ?: null; } +// 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 { + $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]); + return 'queued'; +} + +function clearQueuedShow(PDO $db): void { + $db->exec('DELETE FROM broadcasts WHERE started_at IS NULL'); +} + // Tracks from the most recent broadcast that has started, newest first function recentTracks(PDO $db, int $limit = 100): array { $stmt = $db->prepare(