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:
Daniel MesserandClaude Opus 5.5 committed 2026-10-29 06:45:44 -05:00
1 parent 9a1e8a33a6
commit 74dfb3c146
11 files changed
+417 -50

No files matched your search

+19 -2
View File
@@ -19,12 +19,29 @@ This repo covers the **frontend only** — the public-facing website. Icecast co
- [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. Contains the audio player, now-playing widget, upcoming schedule section, and about section. Show information is updated here manually before each broadcast.
- [now_playing.php](now_playing.php) — Fetches the currently playing track from the Icecast JSON status API and returns it as HTML. Called by the frontend every 15 seconds via `setInterval`. - [now_playing.php](now_playing.php) — Fetches the currently playing track from the Icecast JSON status API and returns it as HTML. Called by the frontend every 15 seconds via `setInterval`.
- [live.php](live.php) — Page for active broadcasts. Player on the left, recently played list on the right; the header background and heading change with the current show.
- [recently_played.php](recently_played.php) — Returns the current broadcast's track history as table rows for `live.php`.
- [includes/icecast.php](includes/icecast.php) — Shared Icecast fetching/parsing, album art lookup and Last.fm link helpers.
- [includes/db.php](includes/db.php) — `getDB()` returns a shared PDO connection (session time zone UTC).
- [includes/broadcasts.php](includes/broadcasts.php) — Broadcast lifecycle and track history logic.
- [cli/poll_icecast.php](cli/poll_icecast.php) — Cron-run poller that records broadcasts and tracks.
- [cli/set_show.php](cli/set_show.php) — Sets which show is airing next (or now).
## Architecture ## Architecture
- **Backend:** PHP only. No Composer, no package manager, no build step. - **Backend:** PHP only. No Composer, no package manager, no build step.
- **Frontend:** HTML5 UP Strata template with custom CSS additions. No npm, no webpack, no preprocessor — edit CSS directly in `assets/css/main.css` or inline `<style>` blocks. - **Frontend:** HTML5 UP Strata template with custom CSS additions. No npm, no webpack, no preprocessor — edit CSS directly in `assets/css/main.css` or inline `<style>` blocks.
- **Now-playing:** `index.php` fetches `now_playing.php` via JavaScript every 15 seconds and injects the result into `#now-playing`. The fetch uses `arrayBuffer` + `TextDecoder` to handle the UTF-8 encoding correctly on the client side. - **Now-playing:** `index.php` and `live.php` fetch `now_playing.php` via JavaScript every 15 seconds and inject the result into `#now-playing`. The fetch uses `arrayBuffer` + `TextDecoder` to handle the UTF-8 encoding correctly on the client side.
## Database
MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is gitignored and uploaded to the server separately; `config/db_credentials.example.php` is the template. Schema is in [sql/schema.sql](sql/schema.sql):
- `shows` — the shows, with an optional `background_image` (filename in `images/backgrounds/`).
- `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 <slug>` to choose the show.
## Icecast Integration ## Icecast Integration
@@ -35,7 +52,7 @@ The status API returns JSON with track metadata under `icestats.source`. The `so
## Known Encoding Quirk ## Known Encoding Quirk
Icecast double-encodes track metadata as Latin-1 interpreted as UTF-8. The fix in [now_playing.php:23-24](now_playing.php#L23-L24) deliberately converts the string from UTF-8 to ISO-8859-1 to strip the outer encoding layer and recover the correct UTF-8 byte sequence. Do not remove or "simplify" this conversion — it is intentional and necessary for non-ASCII characters (accented names, etc.) to display correctly. Icecast double-encodes track metadata as Latin-1 interpreted as UTF-8. The fix in `decodeMetaField()` in [includes/icecast.php](includes/icecast.php) deliberately converts the string from UTF-8 to ISO-8859-1 to strip the outer encoding layer and recover the correct UTF-8 byte sequence. Do not remove or "simplify" this conversion — it is intentional and necessary for non-ASCII characters (accented names, etc.) to display correctly.
## Deployment ## Deployment
+29
View File
@@ -0,0 +1,29 @@
<?php
// Polls Icecast and records track history. Run from cron once a minute; each run polls
// four times, 15 seconds apart:
// * * * * * php /path/to/rfe/cli/poll_icecast.php
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit;
}
require __DIR__ . '/../includes/db.php';
require __DIR__ . '/../includes/broadcasts.php';
// Skip this run if the previous one is still going
$lock = fopen(sys_get_temp_dir() . '/rfe_poll_icecast.lock', 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) exit;
for ($i = 0; $i < 4; $i++) {
if ($i > 0) sleep(15);
$track = fetchCurrentTrack();
if ($track === false) continue; // Status API unreachable; don't treat it as the stream ending
try {
recordPoll(getDB(), $track);
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' ' . $e->getMessage() . "\n");
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
// Sets which show is airing. Run before going live:
// php cli/set_show.php list shows and the current state
// php cli/set_show.php <slug> queue that show for the next broadcast
// (or assign it to the live one if already streaming)
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit;
}
require __DIR__ . '/../includes/db.php';
require __DIR__ . '/../includes/broadcasts.php';
$db = getDB();
$slug = $argv[1] ?? null;
if ($slug === null) {
echo "Shows:\n";
foreach ($db->query('SELECT slug, name FROM shows ORDER BY name') as $show) {
printf(" %-20s %s\n", $show['slug'], $show['name']);
}
$current = currentShow($db);
echo "\nCurrent: " . ($current ? $current['name'] : '(none)') . (liveBroadcast($db) ? ' [live]' : '') . "\n";
exit;
}
$stmt = $db->prepare('SELECT id, name FROM shows WHERE slug = ?');
$stmt->execute([$slug]);
$show = $stmt->fetch();
if (!$show) {
fwrite(STDERR, "Unknown show '{$slug}'. Run without arguments to list shows.\n");
exit(1);
}
$live = liveBroadcast($db);
if ($live !== null) {
$db->prepare('UPDATE broadcasts SET show_id = ? WHERE id = ?')->execute([$show['id'], $live['id']]);
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";
}
View File
Whitespace-only changes.
+105
View File
@@ -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']),
]);
}
+2
View File
@@ -26,5 +26,7 @@ function getDB(): PDO {
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
]); ]);
// Store and compare all DATETIME values in UTC
$pdo->exec("SET time_zone = '+00:00'");
return $pdo; return $pdo;
} }
+62
View File
@@ -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);
}
+51 -3
View File
@@ -1,3 +1,19 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/broadcasts.php';
// The page still renders with the default look if the database is unavailable
$show = null;
try {
$show = currentShow(getDB());
} catch (Throwable $e) {
}
$background = '';
if ($show && $show['background_image'] && is_file(__DIR__ . '/images/backgrounds/' . basename($show['background_image']))) {
$background = 'images/backgrounds/' . rawurlencode(basename($show['background_image']));
}
?>
<!DOCTYPE HTML> <!DOCTYPE HTML>
<!-- <!--
RFE NEW SITE RFE NEW SITE
@@ -54,7 +70,24 @@
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
} }
.track-art {
width: 64px;
}
.track-art img {
display: block;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
</style> </style>
<?php if ($background !== ''): ?>
<style>
#header {
background-image: url("assets/css/images/overlay.png"), url("<?= htmlspecialchars($background, ENT_QUOTES, 'UTF-8') ?>");
}
</style>
<?php endif; ?>
</head> </head>
<body class="is-preload"> <body class="is-preload">
@@ -90,9 +123,24 @@
console.error('Error fetching song data:', error); console.error('Error fetching song data:', error);
}); });
} }
function fetchRecentlyPlayed() {
fetch('recently_played.php')
.then(response => response.arrayBuffer())
.then(buffer => {
const list = document.getElementById('recently-played');
list.innerHTML = new TextDecoder('utf-8').decode(buffer);
// Show play times in the listener's own time zone
list.querySelectorAll('time[datetime]').forEach(el => {
el.textContent = new Date(el.getAttribute('datetime')).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
});
})
.catch(error => console.error('Error fetching recently played:', error));
}
// Fetch immediately and then every 15 seconds // Fetch immediately and then every 15 seconds
fetchNowPlaying(); fetchNowPlaying();
fetchRecentlyPlayed();
setInterval(fetchNowPlaying, 15000); setInterval(fetchNowPlaying, 15000);
setInterval(fetchRecentlyPlayed, 15000);
</script> </script>
</div> </div>
<p><span style = "color:white;">Add <a href="https://radio.cyberpunklibrarian.nohost.me/rfe">Radio Free Elsewhere</a> to VLC or your favourite audio streaming app.</span></p> <p><span style = "color:white;">Add <a href="https://radio.cyberpunklibrarian.nohost.me/rfe">Radio Free Elsewhere</a> to VLC or your favourite audio streaming app.</span></p>
@@ -104,12 +152,13 @@
<!-- Recently Played --> <!-- Recently Played -->
<section id="one"> <section id="one">
<header class="major"> <header class="major">
<h2>Recently Played</h2> <h2>Recently Played<?php if ($show): ?> on <?= htmlspecialchars($show['name'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?></h2>
</header> </header>
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th>
<th>Time</th> <th>Time</th>
<th>Title</th> <th>Title</th>
<th>Artist</th> <th>Artist</th>
@@ -117,9 +166,8 @@
</tr> </tr>
</thead> </thead>
<tbody id="recently-played"> <tbody id="recently-played">
<!-- Populated from the database once track history is in place -->
<tr> <tr>
<td colspan="4">Track history coming soon.</td> <td colspan="5">Loading recently played tracks...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+16 -45
View File
@@ -1,58 +1,29 @@
<?php <?php
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
// Undo double-encoding: strip the outer UTF-8 layer to recover the original correct UTF-8 byte sequence require __DIR__ . '/includes/icecast.php';
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');
}
$icecastStatusURL = "https://radio.cyberpunklibrarian.nohost.me/status-json.xsl"; $track = fetchCurrentTrack();
$response = @file_get_contents($icecastStatusURL);
if ($response === FALSE) { if ($track === false) {
echo "Unable to retrieve stream information."; echo "Unable to retrieve stream information.";
} elseif ($track === null) {
echo "RFE is currently offline!<br>Please check back later!";
} else { } else {
$data = json_decode($response, true); ['title' => $title, 'artist' => $artist, 'album' => $album] = $track;
if (isset($data['icestats']['source'])) { $artUrl = lookupAlbumArt($artist, $album);
$source = $data['icestats']['source']; if ($artUrl !== '') {
$currentStream = (!is_array($source) || isset($source['title'])) ? $source : $source[0]; echo '<img src="' . htmlspecialchars($artUrl, ENT_QUOTES, 'UTF-8') . '" alt="Album art" style="width:100%;max-width:200px;display:block;margin-bottom:8px;border-radius:4px;">';
}
$raw = decodeMetaField($currentStream, 'title'); echo htmlspecialchars($title ?: 'No song metadata available', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$parts = array_map('trim', explode(' - ', $raw, 3)); if ($artist !== '') echo '<br>' . htmlspecialchars($artist, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$title = $parts[0] ?? ''; if ($album !== '') echo '<br>' . htmlspecialchars($album, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$artist = $parts[1] ?? '';
$album = $parts[2] ?? '';
// Look up album art from iTunes Search API if ($artist !== '' && $title !== '') {
$artUrl = ''; $lastfmUrl = lastfmTrackUrl($artist, $title);
if ($artist !== '' && $album !== '') { echo '<br><a href="' . htmlspecialchars($lastfmUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener" style="color:#97C9D3;font-size:0.9em;">More on Last.fm &rarr;</a>';
$query = urlencode($artist . ' ' . $album);
$itunesResponse = @file_get_contents("https://itunes.apple.com/search?term={$query}&entity=album&media=music&limit=1");
if ($itunesResponse !== false) {
$itunesData = json_decode($itunesResponse, true);
if (!empty($itunesData['results'][0]['artworkUrl100'])) {
$artUrl = str_replace('100x100bb', '400x400bb', $itunesData['results'][0]['artworkUrl100']);
}
}
}
if ($artUrl !== '') {
echo '<img src="' . htmlspecialchars($artUrl, ENT_QUOTES, 'UTF-8') . '" alt="Album art" style="width:100%;max-width:200px;display:block;margin-bottom:8px;border-radius:4px;">';
}
echo htmlspecialchars($title ?: 'No song metadata available', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if ($artist !== '') echo '<br>' . htmlspecialchars($artist, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if ($album !== '') echo '<br>' . htmlspecialchars($album, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if ($artist !== '' && $title !== '') {
$lastfmUrl = 'https://www.last.fm/music/' . rawurlencode($artist) . '/_/' . rawurlencode($title);
echo '<br><a href="' . htmlspecialchars($lastfmUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener" style="color:#97C9D3;font-size:0.9em;">More on Last.fm &rarr;</a>';
}
} else {
echo "RFE is currently offline!<br>Please check back later!";
} }
} }
?> ?>
+41
View File
@@ -0,0 +1,41 @@
<?php
// Returns the recently played list for live.php as table rows. Polled alongside now_playing.php.
header('Content-Type: text/html; charset=utf-8');
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/broadcasts.php';
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
try {
$tracks = recentTracks(getDB());
} catch (Throwable $e) {
echo '<tr><td colspan="5">Unable to load recently played tracks.</td></tr>';
exit;
}
if (!$tracks) {
echo '<tr><td colspan="5">Nothing played yet. Tracks will appear here once the show is under way.</td></tr>';
exit;
}
foreach ($tracks as $t) {
// Album art and album title link to the album on Last.fm
$albumUrl = ($t['artist'] !== '' && $t['album'] !== '') ? lastfmAlbumUrl($t['artist'], $t['album']) : '';
$openLink = $albumUrl !== '' ? '<a href="' . e($albumUrl) . '" target="_blank" rel="noopener">' : '';
$closeLink = $albumUrl !== '' ? '</a>' : '';
// Stored in UTC; live.php reformats this in the listener's local time
$iso = str_replace(' ', 'T', $t['played_at']) . 'Z';
$fallback = substr($t['played_at'], 11, 5) . ' UTC';
echo '<tr>';
echo '<td class="track-art">' . ($t['art_url'] !== '' ? $openLink . '<img src="' . e($t['art_url']) . '" alt="Album art for ' . e($t['album']) . '">' . $closeLink : '') . '</td>';
echo '<td><time datetime="' . e($iso) . '">' . e($fallback) . '</time></td>';
echo '<td>' . e($t['title']) . '</td>';
echo '<td>' . e($t['artist']) . '</td>';
echo '<td>' . $openLink . e($t['album']) . $closeLink . '</td>';
echo '</tr>';
}
+48
View File
@@ -0,0 +1,48 @@
-- Radio Free Elsewhere database schema.
-- Apply with: mysql -u <user> -p <database> < sql/schema.sql
-- All DATETIME values are stored in UTC (includes/db.php sets the session time zone).
CREATE TABLE IF NOT EXISTS shows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
-- Filename in images/backgrounds/ used as the live.php header background; NULL keeps the default
background_image VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- One row per streaming session.
-- Pending (queued by cli/set_show.php): started_at IS NULL
-- Live: started_at set, ended_at IS NULL
-- Ended: ended_at set
CREATE TABLE IF NOT EXISTS broadcasts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
show_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
last_seen_at DATETIME NULL,
ended_at DATETIME NULL,
INDEX (started_at),
INDEX (ended_at),
FOREIGN KEY (show_id) REFERENCES shows (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Tracks are cleared ten minutes after their broadcast ends.
CREATE TABLE IF NOT EXISTS played_tracks (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
broadcast_id INT UNSIGNED NOT NULL,
played_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
title VARCHAR(255) NOT NULL,
artist VARCHAR(255) NOT NULL DEFAULT '',
album VARCHAR(255) NOT NULL DEFAULT '',
art_url VARCHAR(512) NOT NULL DEFAULT '',
INDEX (broadcast_id, played_at),
FOREIGN KEY (broadcast_id) REFERENCES broadcasts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO shows (slug, name) VALUES
('omorains-pub', 'O''Morain''s Pub'),
('cosmoeuphoria', 'Cosmoeuphoria'),
('bisexual-surfers', 'Bisexual Surfers From the Twilight of Time'),
('the-bollocks', 'The Bollocks'),
('the-spin', 'The Spin');