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']), ]); }