feat: Complete Docker streaming integration with web interface

- Add live stream integration to both front page and player page
- Add /api/icecast-status endpoint to fetch real-time stream data
- Add drakma dependency for HTTP requests to Icecast
- Fix JavaScript errors on player page with proper error handling
- Add auto-updating 'Now Playing' info every 10 seconds
- Update .gitignore to preserve docker/music/ directory structure
- Add .gitkeep to maintain docker/music/ folder in repository
- Improve user experience with separate public/registered user flows

Integration now complete:
- Front page: Public live stream access
- Player page: Live stream + playlist management for registered users
- Real-time metadata from Icecast JSON API
- Graceful error handling for missing stream backend
This commit is contained in:
Glenn Thompson 2025-10-01 19:22:35 +03:00
parent e61a5a51df
commit d8306f0585
7 changed files with 177 additions and 13 deletions

10
.gitignore vendored
View File

@ -32,8 +32,14 @@ build-sbcl.sh
*.aac *.aac
*.wma *.wma
# Docker music directory # Docker music directory - keep folder but ignore music files
docker/music/ docker/music/*.mp3
docker/music/*.flac
docker/music/*.ogg
docker/music/*.wav
docker/music/*.m4a
docker/music/*.aac
docker/music/*.wma
# Docker build artifacts # Docker build artifacts
docker/.env docker/.env

View File

@ -24,6 +24,7 @@
:cl-fad :cl-fad
:bordeaux-threads :bordeaux-threads
(:interface :auth) (:interface :auth)
:drakma
(:interface :database) (:interface :database)
(:interface :user)) (:interface :user))
:pathname "./" :pathname "./"

View File

@ -307,7 +307,23 @@
("artist" . "The Void") ("artist" . "The Void")
("album" . "Startup Sounds"))) ("album" . "Startup Sounds")))
("listeners" . 0) ("listeners" . 0)
("stream-url" . "http://localhost:8000/asteroid")))) ("stream-url" . "http://localhost:8000/asteroid.mp3")
("stream-status" . "live"))))
;; Live stream status from Icecast
(define-page icecast-status #@"/api/icecast-status" ()
"Get live status from Icecast server"
(setf (radiance:header "Content-Type") "application/json")
(handler-case
(let* ((icecast-url "http://localhost:8000/status-json.xsl")
(response (drakma:http-request icecast-url :want-stream nil)))
(if response
(babel:octets-to-string response :encoding :utf-8) ; Convert response to string
(cl-json:encode-json-to-string
`(("error" . "Could not connect to Icecast server")))))
(error (e)
(cl-json:encode-json-to-string
`(("error" . ,(format nil "Icecast connection failed: ~a" e)))))))
;; RADIANCE server management functions ;; RADIANCE server management functions

View File

@ -0,0 +1,33 @@
services:
icecast:
image: infiniteproject/icecast:latest
container_name: asteroid-icecast
ports:
- "8000:8000"
volumes:
- ./icecast.xml:/etc/icecast2/icecast.xml:ro
environment:
- ICECAST_SOURCE_PASSWORD=H1tn31EhsyLrfRmo
- ICECAST_ADMIN_PASSWORD=asteroid_admin_2024
- ICECAST_RELAY_PASSWORD=asteroid_relay_2024
restart: unless-stopped
networks:
- asteroid-network
liquidsoap:
build:
context: .
dockerfile: Dockerfile.liquidsoap
container_name: asteroid-liquidsoap
depends_on:
- icecast
volumes:
- /mnt/remote-music/Music:/app/music:ro
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
restart: unless-stopped
networks:
- asteroid-network
networks:
asteroid-network:
driver: bridge

0
docker/music/.gitkeep Normal file
View File

View File

@ -46,10 +46,47 @@
<h2>Now Playing</h2> <h2>Now Playing</h2>
<p>Artist: <span data-text="now-playing-artist">The Void</span></p> <p>Artist: <span data-text="now-playing-artist">The Void</span></p>
<p>Track: <span data-text="now-playing-track">Silence</span></p> <p>Track: <span data-text="now-playing-track">Silence</span></p>
<p>Album: <span data-text="now-playing-album">Startup Sounds</span></p> <p>Listeners: <span data-text="listeners">0</span></p>
<p>Duration: <span data-text="now-playing-duration">∞</span></p>
</div> </div>
</main> </main>
</div> </div>
<script>
// Update now playing info from Icecast
function updateNowPlaying() {
fetch('/asteroid/api/icecast-status')
.then(response => response.json())
.then(data => {
if (data.icestats && data.icestats.source) {
// Find the high quality stream (asteroid.mp3)
const sources = Array.isArray(data.icestats.source) ? data.icestats.source : [data.icestats.source];
const mainStream = sources.find(s => s.listenurl && s.listenurl.includes('asteroid.mp3'));
if (mainStream && mainStream.title) {
// Parse "Artist - Track" format
const titleParts = mainStream.title.split(' - ');
const artist = titleParts.length > 1 ? titleParts[0] : 'Unknown Artist';
const track = titleParts.length > 1 ? titleParts.slice(1).join(' - ') : mainStream.title;
document.querySelector('[data-text="now-playing-artist"]').textContent = artist;
document.querySelector('[data-text="now-playing-track"]').textContent = track;
document.querySelector('[data-text="listeners"]').textContent = mainStream.listeners || '0';
// Update stream status
const statusElement = document.querySelector('.live-stream p:nth-child(3) span');
if (statusElement) {
statusElement.textContent = '● LIVE - ' + track;
statusElement.style.color = '#00ff00';
}
}
}
})
.catch(error => console.log('Could not fetch stream status:', error));
}
// Update every 10 seconds
updateNowPlaying();
setInterval(updateNowPlaying, 10000);
</script>
</body> </body>
</html> </html>

View File

@ -14,9 +14,23 @@
<a href="/asteroid/admin">Admin Dashboard</a> <a href="/asteroid/admin">Admin Dashboard</a>
</div> </div>
<!-- Live Stream Section -->
<div class="player-section">
<h2>🔴 Live Radio Stream</h2>
<div class="live-player">
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
<p><strong>Listeners:</strong> <span id="live-listeners">0</span></p>
<audio controls style="width: 100%; margin: 10px 0;">
<source src="http://localhost:8000/asteroid.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<p><em>Listen to the live Asteroid Radio stream</em></p>
</div>
</div>
<!-- Track Browser --> <!-- Track Browser -->
<div class="player-section"> <div class="player-section">
<h2>Track Library</h2> <h2>Personal Track Library</h2>
<div class="track-browser"> <div class="track-browser">
<input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input"> <input type="text" id="search-tracks" placeholder="Search tracks..." class="search-input">
<div id="track-list" class="track-list"> <div id="track-list" class="track-list">
@ -122,11 +136,13 @@
document.getElementById('volume-slider').addEventListener('input', updateVolume); document.getElementById('volume-slider').addEventListener('input', updateVolume);
// Audio player events // Audio player events
audioPlayer.addEventListener('loadedmetadata', updateTimeDisplay); if (audioPlayer) {
audioPlayer.addEventListener('timeupdate', updateTimeDisplay); audioPlayer.addEventListener('loadedmetadata', updateTimeDisplay);
audioPlayer.addEventListener('ended', handleTrackEnd); audioPlayer.addEventListener('timeupdate', updateTimeDisplay);
audioPlayer.addEventListener('play', () => updatePlayButton('⏸️ Pause')); audioPlayer.addEventListener('ended', handleTrackEnd);
audioPlayer.addEventListener('pause', () => updatePlayButton('▶️ Play')); audioPlayer.addEventListener('play', () => updatePlayButton('⏸️ Pause'));
audioPlayer.addEventListener('pause', () => updatePlayButton('▶️ Play'));
}
// Playlist controls // Playlist controls
document.getElementById('create-playlist').addEventListener('click', createPlaylist); document.getElementById('create-playlist').addEventListener('click', createPlaylist);
@ -136,7 +152,10 @@
async function loadTracks() { async function loadTracks() {
try { try {
const response = await fetch('/admin/tracks'); const response = await fetch('/asteroid/api/tracks');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json(); const data = await response.json();
if (data.status === 'success') { if (data.status === 'success') {
@ -269,7 +288,9 @@
function updateVolume() { function updateVolume() {
const volume = document.getElementById('volume-slider').value / 100; const volume = document.getElementById('volume-slider').value / 100;
audioPlayer.volume = volume; if (audioPlayer) {
audioPlayer.volume = volume;
}
} }
function updateTimeDisplay() { function updateTimeDisplay() {
@ -363,6 +384,56 @@
// Initialize volume // Initialize volume
updateVolume(); updateVolume();
// Live stream functionality
function updateLiveStream() {
try {
fetch('/asteroid/api/icecast-status')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Live stream data:', data); // Debug log
if (data.icestats && data.icestats.source) {
const sources = Array.isArray(data.icestats.source) ? data.icestats.source : [data.icestats.source];
const mainStream = sources.find(s => s.listenurl && s.listenurl.includes('asteroid.mp3'));
if (mainStream && mainStream.title) {
const titleParts = mainStream.title.split(' - ');
const artist = titleParts.length > 1 ? titleParts[0] : 'Unknown Artist';
const track = titleParts.length > 1 ? titleParts.slice(1).join(' - ') : mainStream.title;
const nowPlayingEl = document.getElementById('live-now-playing');
const listenersEl = document.getElementById('live-listeners');
if (nowPlayingEl) nowPlayingEl.textContent = `${artist} - ${track}`;
if (listenersEl) listenersEl.textContent = mainStream.listeners || '0';
console.log('Updated live stream info:', `${artist} - ${track}`, 'Listeners:', mainStream.listeners);
} else {
console.log('No main stream found or no title');
}
} else {
console.log('No icestats or source in response');
}
})
.catch(error => {
console.error('Live stream fetch error:', error);
const nowPlayingEl = document.getElementById('live-now-playing');
if (nowPlayingEl) nowPlayingEl.textContent = 'Stream unavailable';
});
} catch (error) {
console.error('Live stream update error:', error);
}
}
// Update live stream info every 10 seconds
setTimeout(updateLiveStream, 1000); // Initial update after 1 second
setInterval(updateLiveStream, 10000);
</script> </script>
</body> </body>
</html> </html>