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:
parent
e61a5a51df
commit
d8306f0585
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 "./"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue