Add recently played tracking with shows and broadcasts
- sql/schema.sql: shows, broadcasts and played_tracks tables, seeded with the five shows - cli/poll_icecast.php: cron-run poller that records broadcasts and tracks every 15 seconds while streaming; tracks are cleared ten minutes after a broadcast ends - cli/set_show.php: queue the next show, or assign one to the live broadcast - recently_played.php: track history rows for live.php, with album art and album title linking to Last.fm - live.php: recently played list, show name in heading, and per-show header background - includes/icecast.php: Icecast parsing, album art and Last.fm helpers shared by now_playing.php and the poller - includes/db.php: use UTC for the database session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a1e8a33a6
commit
74dfb3c146
11 files changed
+417
-50
No files matched your search
@@ -0,0 +1,105 @@
|
||||
<?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;
|
||||
|
||||
function liveBroadcast(PDO $db): ?array {
|
||||
$row = $db->query(
|
||||
'SELECT * FROM broadcasts
|
||||
WHERE started_at IS NOT NULL AND ended_at IS NULL
|
||||
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
|
||||
function currentShow(PDO $db): ?array {
|
||||
$row = $db->query(
|
||||
'SELECT s.* FROM broadcasts b
|
||||
JOIN shows s ON s.id = b.show_id
|
||||
WHERE b.ended_at IS NULL
|
||||
ORDER BY b.started_at IS NULL, b.started_at DESC, b.created_at DESC
|
||||
LIMIT 1'
|
||||
)->fetch();
|
||||
return $row ?: 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']),
|
||||
]);
|
||||
}
|
||||
@@ -26,5 +26,7 @@ function getDB(): PDO {
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
// Store and compare all DATETIME values in UTC
|
||||
$pdo->exec("SET time_zone = '+00:00'");
|
||||
return $pdo;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
// Shared Icecast helpers: fetching and parsing the current track, album art and Last.fm links.
|
||||
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
const ICECAST_STATUS_URL = 'https://radio.cyberpunklibrarian.nohost.me/status-json.xsl';
|
||||
|
||||
// Undo double-encoding: strip the outer UTF-8 layer to recover the original correct UTF-8 byte sequence
|
||||
function decodeMetaField(array $stream, string $key): string {
|
||||
if (!isset($stream[$key]) || $stream[$key] === '') return '';
|
||||
return mb_convert_encoding($stream[$key], 'ISO-8859-1', 'UTF-8');
|
||||
}
|
||||
|
||||
function httpGet(string $url) {
|
||||
$context = stream_context_create(['http' => ['timeout' => 10]]);
|
||||
return @file_get_contents($url, false, $context);
|
||||
}
|
||||
|
||||
// Returns ['title', 'artist', 'album'] while streaming, null when offline,
|
||||
// or false when the status API could not be reached.
|
||||
function fetchCurrentTrack() {
|
||||
$response = httpGet(ICECAST_STATUS_URL);
|
||||
if ($response === false) return false;
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!isset($data['icestats']['source'])) return null;
|
||||
|
||||
$source = $data['icestats']['source'];
|
||||
$currentStream = (!is_array($source) || isset($source['title'])) ? $source : $source[0];
|
||||
|
||||
$raw = decodeMetaField($currentStream, 'title');
|
||||
$parts = array_map('trim', explode(' - ', $raw, 3));
|
||||
return [
|
||||
'title' => $parts[0] ?? '',
|
||||
'artist' => $parts[1] ?? '',
|
||||
'album' => $parts[2] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// Look up album art from iTunes Search API
|
||||
function lookupAlbumArt(string $artist, string $album): string {
|
||||
if ($artist === '' || $album === '') return '';
|
||||
|
||||
$query = urlencode($artist . ' ' . $album);
|
||||
$itunesResponse = httpGet("https://itunes.apple.com/search?term={$query}&entity=album&media=music&limit=1");
|
||||
if ($itunesResponse === false) return '';
|
||||
|
||||
$itunesData = json_decode($itunesResponse, true);
|
||||
if (empty($itunesData['results'][0]['artworkUrl100'])) return '';
|
||||
return str_replace('100x100bb', '400x400bb', $itunesData['results'][0]['artworkUrl100']);
|
||||
}
|
||||
|
||||
function lastfmTrackUrl(string $artist, string $title): string {
|
||||
return 'https://www.last.fm/music/' . rawurlencode($artist) . '/_/' . rawurlencode($title);
|
||||
}
|
||||
|
||||
function lastfmAlbumUrl(string $artist, string $album): string {
|
||||
return 'https://www.last.fm/music/' . rawurlencode($artist) . '/' . rawurlencode($album);
|
||||
}
|
||||
Reference in new issue
Block a user