Files
radio-free-elsewhere/includes/broadcasts.php
T
Daniel MesserandClaude Opus 5.5 170ae6eed0 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>
2026-10-30 16:11:22 -05:00

146 lines
6.4 KiB
PHP

<?php
// Broadcast and track-history logic shared by the poller, the CLI tools and the live page.
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit;
}
require_once __DIR__ . '/icecast.php';
// A live broadcast not seen for this long is considered ended (covers brief encoder drop-outs)
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;
}
function pendingBroadcast(PDO $db): ?array {
$row = $db->query(
'SELECT * FROM broadcasts
WHERE started_at IS NULL AND ended_at IS NULL
ORDER BY created_at DESC LIMIT 1'
)->fetch();
return $row ?: null;
}
// The show for the live broadcast, or the queued one if nothing is live yet,
// with the broadcast's scheduled_at, episode_title and started_at. Stale broadcasts are skipped as in liveBroadcast().
function currentShow(PDO $db): ?array {
$row = $db->query(
'SELECT s.*, b.scheduled_at, b.episode_title, 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.
// $scheduledAt (UTC) is the advertised start time of a queued show; $episodeTitle is optional and replaces
// 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);
if ($live !== null) {
$db->prepare('UPDATE broadcasts SET show_id = ?, episode_title = ? WHERE id = ?')->execute([$showId, $episodeTitle, $live['id']]);
return 'live';
}
clearQueuedShow($db);
$db->prepare('INSERT INTO broadcasts (show_id, scheduled_at, episode_title) VALUES (?, ?, ?)')->execute([$showId, $scheduledAt, $episodeTitle]);
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(
'SELECT * FROM played_tracks
WHERE broadcast_id = (SELECT id FROM broadcasts WHERE started_at IS NOT NULL ORDER BY started_at DESC LIMIT 1)
ORDER BY played_at DESC, id DESC
LIMIT ?'
);
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
// Record one poll of the Icecast status. $track is the result of fetchCurrentTrack(): an array while
// streaming or null when offline. (Don't call this when the status API was unreachable.)
function recordPoll(PDO $db, ?array $track): void {
$db->exec(
'UPDATE broadcasts SET ended_at = last_seen_at
WHERE started_at IS NOT NULL AND ended_at IS NULL
AND last_seen_at < NOW() - INTERVAL ' . BROADCAST_GRACE_MINUTES . ' MINUTE'
);
$db->exec(
'DELETE pt FROM played_tracks pt
JOIN broadcasts b ON b.id = pt.broadcast_id
WHERE b.ended_at < NOW() - INTERVAL ' . TRACK_RETENTION_MINUTES . ' MINUTE'
);
if ($track === null) return;
$broadcast = liveBroadcast($db);
if ($broadcast !== null) {
$db->prepare('UPDATE broadcasts SET last_seen_at = NOW() WHERE id = ?')->execute([$broadcast['id']]);
$broadcastId = (int)$broadcast['id'];
} elseif (($pending = pendingBroadcast($db)) !== null) {
$db->prepare('UPDATE broadcasts SET started_at = NOW(), last_seen_at = NOW() WHERE id = ?')->execute([$pending['id']]);
$broadcastId = (int)$pending['id'];
} else {
// Nobody queued a show; record the broadcast anyway so track history still works
$db->exec('INSERT INTO broadcasts (started_at, last_seen_at) VALUES (NOW(), NOW())');
$broadcastId = (int)$db->lastInsertId();
}
if ($track['title'] === '') return;
$stmt = $db->prepare('SELECT title, artist, album FROM played_tracks WHERE broadcast_id = ? ORDER BY played_at DESC, id DESC LIMIT 1');
$stmt->execute([$broadcastId]);
$last = $stmt->fetch();
if ($last && $last['title'] === $track['title'] && $last['artist'] === $track['artist'] && $last['album'] === $track['album']) {
return;
}
$db->prepare('INSERT INTO played_tracks (broadcast_id, title, artist, album, art_url) VALUES (?, ?, ?, ?, ?)')
->execute([
$broadcastId,
mb_substr($track['title'], 0, 255),
mb_substr($track['artist'], 0, 255),
mb_substr($track['album'], 0, 255),
lookupAlbumArt($track['artist'], $track['album']),
]);
}