Compare commits

...

4 Commits

Author SHA1 Message Date
glenneth cc32ee1d94 Improve player UI and reduce buffering
- Remove aggressive stream reconnect
- Reduce live stream buffering delay
- Clean up debug logging
- Fix playlist loading errors
2025-10-14 14:41:59 +03:00
glenneth 08c88baa12 Improve audio quality and streaming performance
- Add 5-second crossfades between tracks
- Use ReplayGain for consistent volume (removed normalize())
- Add audio compression to prevent clipping
- Liquidsoap watches playlist file and reloads every 5 seconds
- Fallback to random playback when queue is empty
- Fix playlist to play all tracks in order
2025-10-14 14:41:43 +03:00
glenneth 3b7ed635f0 Add admin UI for stream queue management
- Queue management section with add/remove/clear controls
- Add to Queue button on each track in library browser
- Search tracks and add to queue
- Add 10 random tracks button
- Live stream monitor with Now Playing display
- Toast notifications for user feedback
- Real-time queue updates
2025-10-14 14:41:25 +03:00
glenneth 366e481867 Add stream queue control system
- New stream-control.lisp for queue management backend
- M3U playlist generation with Docker path mapping
- API endpoints for add/remove/clear/reorder queue
- Fix library scan deduplication
- Add stream control documentation
2025-10-14 14:41:03 +03:00
14 changed files with 1044 additions and 67 deletions

View File

@ -38,5 +38,6 @@
(:file "stream-media")
(:file "user-management")
(:file "playlist-management")
(:file "stream-control")
(:file "auth-routes")
(:file "asteroid")))

View File

@ -76,8 +76,6 @@
(user-id-raw (gethash "_id" user))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
(playlists (get-user-playlists user-id)))
(format t "Fetching playlists for user-id: ~a~%" user-id)
(format t "Found ~a playlists~%" (length playlists))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (playlist)
(let ((name-val (gethash "name" playlist))
@ -85,7 +83,6 @@
(track-ids-val (gethash "track-ids" playlist))
(created-val (gethash "created-date" playlist))
(id-val (gethash "_id" playlist)))
(format t "Playlist ID: ~a (type: ~a)~%" id-val (type-of id-val))
;; Calculate track count from comma-separated string
;; Handle nil, empty string, or list containing empty string
(let* ((track-ids-str (if (listp track-ids-val)
@ -114,9 +111,7 @@
(let* ((user (get-current-user))
(user-id-raw (gethash "_id" user))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
(format t "Creating playlist for user-id: ~a, name: ~a~%" user-id name)
(create-playlist user-id name description)
(format t "Playlist created successfully~%")
(if (string= "true" (post/get "browser"))
(redirect "/asteroid/")
(api-output `(("status" . "success")
@ -148,8 +143,6 @@
(handler-case
(let* ((id (parse-integer playlist-id :junk-allowed t))
(playlist (get-playlist-by-id id)))
(format t "Looking for playlist ID: ~a~%" id)
(format t "Found playlist: ~a~%" (if playlist "YES" "NO"))
(if playlist
(let* ((track-ids-raw (gethash "tracks" playlist))
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
@ -197,24 +190,101 @@
("message" . ,(format nil "Error retrieving tracks: ~a" e)))
:status 500))))
;; API endpoint to get track by ID (for streaming)
(define-page api-get-track-by-id #@"/api/tracks/(.*)" (:uri-groups (track-id))
"Retrieve track from database by ID"
(let* ((id (if (stringp track-id) (parse-integer track-id) track-id))
(tracks (db:select "tracks" (db:query (:= '_id id)))))
(when tracks (first tracks))))
;; Stream Control API Endpoints
(define-api asteroid/stream/queue () ()
"Get the current stream queue"
(require-role :admin)
(handler-case
(let ((queue (get-stream-queue)))
(api-output `(("status" . "success")
("queue" . ,(mapcar (lambda (track-id)
(let ((track (get-track-by-id track-id)))
(when track
`(("id" . ,track-id)
("title" . ,(gethash "title" track))
("artist" . ,(gethash "artist" track))
("album" . ,(gethash "album" track))))))
queue)))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error getting queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
"Add a track to the stream queue"
(require-role :admin)
(handler-case
(let ((tr-id (parse-integer track-id :junk-allowed t))
(pos (if (string= position "next") :next :end)))
(add-to-stream-queue tr-id pos)
(api-output `(("status" . "success")
("message" . "Track added to stream queue"))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error adding to queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/remove (track-id) ()
"Remove a track from the stream queue"
(require-role :admin)
(handler-case
(let ((tr-id (parse-integer track-id :junk-allowed t)))
(remove-from-stream-queue tr-id)
(api-output `(("status" . "success")
("message" . "Track removed from stream queue"))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error removing from queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/clear () ()
"Clear the entire stream queue"
(require-role :admin)
(handler-case
(progn
(clear-stream-queue)
(api-output `(("status" . "success")
("message" . "Stream queue cleared"))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error clearing queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/add-playlist (playlist-id) ()
"Add all tracks from a playlist to the stream queue"
(require-role :admin)
(handler-case
(let ((pl-id (parse-integer playlist-id :junk-allowed t)))
(add-playlist-to-stream-queue pl-id)
(api-output `(("status" . "success")
("message" . "Playlist added to stream queue"))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error adding playlist to queue: ~a" e)))
:status 500))))
(define-api asteroid/stream/queue/reorder (track-ids) ()
"Reorder the stream queue (expects comma-separated track IDs)"
(require-role :admin)
(handler-case
(let ((ids (mapcar (lambda (id-str) (parse-integer id-str :junk-allowed t))
(cl-ppcre:split "," track-ids))))
(reorder-stream-queue ids)
(api-output `(("status" . "success")
("message" . "Stream queue reordered"))))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error reordering queue: ~a" e)))
:status 500))))
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
(format t "get-track-by-id called with: ~a (type: ~a)~%" track-id (type-of track-id))
;; Try direct query first
(let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
(if (> (length tracks) 0)
(progn
(format t "Found via direct query~%")
(first tracks))
(first tracks)
;; If not found, search manually (ID might be stored as list)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(format t "Searching through ~a tracks manually~%" (length all-tracks))
(find-if (lambda (track)
(let ((stored-id (gethash "_id" track)))
(or (equal stored-id track-id)
@ -500,7 +570,9 @@
:liquidsoap-status (check-liquidsoap-status)
:icecast-status (check-icecast-status)
:track-count (format nil "~d" track-count)
:library-path "/home/glenn/Projects/Code/asteroid/music/library/")))
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
:stream-base-url *stream-base-url*
:default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac"))))
;; User Management page (requires authentication)
(define-page users-management #@"/admin/user" ()

View File

@ -14,20 +14,46 @@ settings.server.telnet.set(true)
settings.server.telnet.port.set(1234)
settings.server.telnet.bind_addr.set("0.0.0.0")
# Create playlist source from mounted music directory
# Use playlist.safe which starts playing immediately without full scan
radio = playlist.safe(
mode="randomize",
# Create playlist source from generated M3U file
# This file is managed by Asteroid's stream control system
# Falls back to directory scan if playlist file doesn't exist
radio = playlist(
mode="normal", # Play in order (not randomized)
reload=5, # Check for playlist updates every 5 seconds
reload_mode="watch", # Watch file for changes
"/app/stream-queue.m3u"
)
# Fallback to directory scan if playlist file is empty/missing
radio_fallback = playlist.safe(
mode="randomize",
reload=3600,
"/app/music/"
)
# Add some audio processing
radio = amplify(1.0, radio)
radio = normalize(radio)
# Use main playlist, fall back to directory scan
radio = fallback(track_sensitive=false, [radio, radio_fallback])
# Add crossfade between tracks
radio = crossfade(radio)
# Add some audio processing
# Use ReplayGain for consistent volume without pumping
radio = amplify(1.0, override="replaygain", radio)
# Add smooth crossfade between tracks (5 seconds)
radio = crossfade(
duration=5.0, # 5 second crossfade
fade_in=3.0, # 3 second fade in
fade_out=3.0, # 3 second fade out
radio
)
# Add a compressor to prevent clipping
radio = compress(
ratio=3.0, # Compression ratio
threshold=-15.0, # Threshold in dB
attack=50.0, # Attack time in ms
release=400.0, # Release time in ms
radio
)
# Create a fallback with emergency content
emergency = sine(440.0)

View File

@ -26,6 +26,7 @@ services:
volumes:
- ../music/library:/app/music:ro
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
- ../stream-queue.m3u:/app/stream-queue.m3u:ro
restart: unless-stopped
networks:
- asteroid-network

View File

@ -23,6 +23,7 @@ docker compose ps
echo ""
echo "🎵 Asteroid Radio is now streaming!"
echo "📡 High Quality: http://localhost:8000/asteroid.mp3"
echo "📡 Low Quality: http://localhost:8000/asteroid-low.mp3"
echo "🔧 Admin Panel: http://localhost:8000/admin/"
echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
echo "🔧 Admin Panel: http://localhost:8000/admin/"

209
docs/STREAM-CONTROL.org Normal file
View File

@ -0,0 +1,209 @@
#+TITLE: Stream Queue Control System
#+AUTHOR: Asteroid Radio Team
#+DATE: 2025-10-14
* Overview
The stream queue control system allows administrators to manage what plays on the main Asteroid Radio broadcast stream. Instead of random playback from the music library, you can now curate the exact order of tracks.
* How It Works
1. *Stream Queue* - An ordered list of track IDs maintained in memory
2. *M3U Generation* - The queue is converted to a =stream-queue.m3u= file
3. *Liquidsoap Integration* - Liquidsoap reads the M3U file and reloads it every 60 seconds
4. *Fallback* - If the queue is empty, Liquidsoap falls back to random directory playback
* API Endpoints (Admin Only)
All endpoints require admin authentication.
** Get Current Queue
#+BEGIN_SRC
GET /api/asteroid/stream/queue
#+END_SRC
Returns the current stream queue with track details.
** Add Track to Queue
#+BEGIN_SRC
POST /api/asteroid/stream/queue/add
Parameters:
- track_id: ID of track to add
- position: "end" (default) or "next"
#+END_SRC
Adds a track to the end of the queue or as the next track to play.
** Remove Track from Queue
#+BEGIN_SRC
POST /api/asteroid/stream/queue/remove
Parameters:
- track_id: ID of track to remove
#+END_SRC
Removes a specific track from the queue.
** Clear Queue
#+BEGIN_SRC
POST /api/asteroid/stream/queue/clear
#+END_SRC
Clears the entire queue (will fall back to random playback).
** Add Playlist to Queue
#+BEGIN_SRC
POST /api/asteroid/stream/queue/add-playlist
Parameters:
- playlist_id: ID of playlist to add
#+END_SRC
Adds all tracks from a user playlist to the stream queue.
** Reorder Queue
#+BEGIN_SRC
POST /api/asteroid/stream/queue/reorder
Parameters:
- track_ids: Comma-separated list of track IDs in desired order
#+END_SRC
Completely reorders the queue with a new track order.
* Usage Examples
** Building a Stream Queue
#+BEGIN_SRC bash
# Add a specific track to the end
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
-d "track-id=42" \
-H "Cookie: radiance-session=..."
# Add a track to play next
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
-d "track-id=43&position=next" \
-H "Cookie: radiance-session=..."
# Add an entire playlist
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add-playlist \
-d "playlist-id=5" \
-H "Cookie: radiance-session=..."
#+END_SRC
** Managing the Queue
#+BEGIN_SRC bash
# View current queue
curl http://localhost:8080/api/asteroid/stream/queue \
-H "Cookie: radiance-session=..."
# Remove a track
curl -X POST http://localhost:8080/api/asteroid/stream/queue/remove \
-d "track-id=42" \
-H "Cookie: radiance-session=..."
# Clear everything
curl -X POST http://localhost:8080/api/asteroid/stream/queue/clear \
-H "Cookie: radiance-session=..."
#+END_SRC
* Lisp Functions
If you're working directly in the Lisp REPL:
#+BEGIN_SRC lisp
;; Add tracks to queue
(add-to-stream-queue 42 :end)
(add-to-stream-queue 43 :next)
;; View queue
(get-stream-queue)
;; Add a playlist
(add-playlist-to-stream-queue 5)
;; Remove a track
(remove-from-stream-queue 42)
;; Clear queue
(clear-stream-queue)
;; Reorder queue
(reorder-stream-queue '(43 44 45 46))
;; Build smart queues
(build-smart-queue "electronic" 20)
(build-queue-from-artist "Nine Inch Nails" 15)
;; Manually regenerate playlist file
(regenerate-stream-playlist)
#+END_SRC
* File Locations
- *Stream Queue File*: =/home/glenn/Projects/Code/asteroid/stream-queue.m3u=
- *Docker Mount*: =/app/stream-queue.m3u= (inside Liquidsoap container)
- *Liquidsoap Config*: =docker/asteroid-radio-docker.liq=
* How Liquidsoap Reads Updates
The Liquidsoap configuration reloads the playlist file every 60 seconds:
#+BEGIN_SRC liquidsoap
radio = playlist.safe(
mode="normal",
reload=60,
"/app/stream-queue.m3u"
)
#+END_SRC
This means changes to the queue will take effect within 1 minute.
* Stream History
The system also tracks recently played tracks:
#+BEGIN_SRC lisp
;; Get last 10 played tracks
(get-stream-history 10)
;; Add to history (usually automatic)
(add-to-stream-history 42)
#+END_SRC
* Future Enhancements
- [ ] Web UI for queue management (drag-and-drop reordering)
- [ ] Telnet integration for real-time skip/next commands
- [ ] Scheduled programming (time-based queue switching)
- [ ] Auto-queue filling (automatically add tracks when queue runs low)
- [ ] Genre-based smart queues
- [ ] Listener request system
* Troubleshooting
** Queue changes not taking effect
- Wait up to 60 seconds for Liquidsoap to reload
- Check that =stream-queue.m3u= was generated correctly
- Verify Docker volume mount is working: =docker exec asteroid-liquidsoap ls -la /app/stream-queue.m3u=
- Check Liquidsoap logs: =docker logs asteroid-liquidsoap=
** Empty queue falls back to random
This is expected behavior. The system will play random tracks from the music library when the queue is empty to ensure continuous streaming.
** Playlist file not updating
- Ensure Asteroid server has write permissions to the project directory
- Check that =regenerate-stream-playlist= is being called after queue modifications
- Verify the file exists: =ls -la stream-queue.m3u=
* Integration with Admin Interface
The stream control system is designed to be integrated into the admin web interface. Future work will add:
- Visual queue editor with drag-and-drop
- "Add to Stream Queue" buttons on track listings
- "Queue Playlist" buttons on playlist pages
- Real-time queue display showing what's currently playing
- Skip/Next controls for immediate playback changes (via Telnet)

View File

@ -431,6 +431,89 @@ body .queue-item:last-child{
margin-bottom: 0;
}
body .queue-position{
background: #00ff00;
color: #000;
padding: 4px 8px;
border-radius: 3px;
font-weight: bold;
margin-right: 10px;
min-width: 30px;
text-align: center;
display: inline-block;
}
body .queue-track-info{
flex: 1;
margin-right: 10px;
}
body .queue-track-info.track-title{
font-weight: bold;
margin-bottom: 2px;
}
body .queue-track-info.track-artist{
font-size: 0.9em;
color: #888;
}
body .queue-actions{
margin-top: 20px;
padding: 15px;
background: #0a0a0a;
border: 1px solid #2a3441;
border-radius: 4px;
}
body .queue-list{
border: 1px solid #2a3441;
background: #0a0a0a;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
padding: 10px;
margin-bottom: 20px;
}
body .search-results{
margin-top: 10px;
max-height: 300px;
overflow-y: auto;
}
body .search-result-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border: 1px solid #2a3441;
margin-bottom: 5px;
background: #0a0a0a;
border-radius: 3px;
}
body .search-result-item:hover{
background: #1a1a1a;
border-color: #00ff00;
}
body .search-result-item.track-info{
flex: 1;
}
body .search-result-item.track-actions{
display: flex;
gap: 5px;
}
body .empty-state{
text-align: center;
color: #666;
padding: 30px;
font-style: italic;
}
body .empty-queue{
text-align: center;
color: #666;

View File

@ -347,6 +347,77 @@
:border-bottom none
:margin-bottom 0)
(.queue-position
:background "#00ff00"
:color "#000"
:padding "4px 8px"
:border-radius "3px"
:font-weight bold
:margin-right "10px"
:min-width "30px"
:text-align center
:display inline-block)
(.queue-track-info
:flex 1
:margin-right "10px")
((:and .queue-track-info .track-title)
:font-weight bold
:margin-bottom "2px")
((:and .queue-track-info .track-artist)
:font-size "0.9em"
:color "#888")
(.queue-actions
:margin-top "20px"
:padding "15px"
:background "#0a0a0a"
:border "1px solid #2a3441"
:border-radius "4px")
(.queue-list
:border "1px solid #2a3441"
:background "#0a0a0a"
:min-height "200px"
:max-height "400px"
:overflow-y auto
:padding "10px"
:margin-bottom "20px")
(.search-results
:margin-top "10px"
:max-height "300px"
:overflow-y auto)
(.search-result-item
:display flex
:justify-content space-between
:align-items center
:padding "10px"
:border "1px solid #2a3441"
:margin-bottom "5px"
:background "#0a0a0a"
:border-radius "3px")
((:and .search-result-item :hover)
:background "#1a1a1a"
:border-color "#00ff00")
((:and .search-result-item .track-info)
:flex 1)
((:and .search-result-item .track-actions)
:display flex
:gap "5px")
(.empty-state
:text-align center
:color "#666"
:padding "30px"
:font-style italic)
(.empty-queue
:text-align center
:color "#666"

View File

@ -25,6 +25,30 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('player-pause').addEventListener('click', pausePlayer);
document.getElementById('player-stop').addEventListener('click', stopPlayer);
document.getElementById('player-resume').addEventListener('click', resumePlayer);
// Queue controls
const refreshQueueBtn = document.getElementById('refresh-queue');
const clearQueueBtn = document.getElementById('clear-queue-btn');
const addRandomBtn = document.getElementById('add-random-tracks');
const queueSearchInput = document.getElementById('queue-track-search');
if (refreshQueueBtn) refreshQueueBtn.addEventListener('click', loadStreamQueue);
if (clearQueueBtn) clearQueueBtn.addEventListener('click', clearStreamQueue);
if (addRandomBtn) addRandomBtn.addEventListener('click', addRandomTracks);
if (queueSearchInput) queueSearchInput.addEventListener('input', searchTracksForQueue);
// Load initial queue
loadStreamQueue();
// Setup live stream monitor
const liveAudio = document.getElementById('live-stream-audio');
if (liveAudio) {
liveAudio.preload = 'none';
}
// Update live stream info
updateLiveStreamInfo();
setInterval(updateLiveStreamInfo, 10000); // Every 10 seconds
});
// Load tracks from API
@ -81,6 +105,7 @@ function renderPage() {
<div class="track-actions">
<button onclick="playTrack(${track.id})" class="btn btn-sm btn-success"> Play</button>
<button onclick="streamTrack(${track.id})" class="btn btn-sm btn-info">🎵 Stream</button>
<button onclick="addToQueue(${track.id}, 'end')" class="btn btn-sm btn-primary"> Add to Queue</button>
<button onclick="deleteTrack(${track.id})" class="btn btn-sm btn-danger">🗑 Delete</button>
</div>
</div>
@ -304,3 +329,267 @@ function openIncomingFolder() {
// Update player status every 5 seconds
setInterval(updatePlayerStatus, 5000);
// ========================================
// Stream Queue Management
// ========================================
let streamQueue = [];
let queueSearchTimeout = null;
// Load current stream queue
async function loadStreamQueue() {
try {
const response = await fetch('/api/asteroid/stream/queue');
const result = await response.json();
const data = result.data || result;
if (data.status === 'success') {
streamQueue = data.queue || [];
displayStreamQueue();
}
} catch (error) {
console.error('Error loading stream queue:', error);
document.getElementById('stream-queue-container').innerHTML =
'<div class="error">Error loading queue</div>';
}
}
// Display stream queue
function displayStreamQueue() {
const container = document.getElementById('stream-queue-container');
if (streamQueue.length === 0) {
container.innerHTML = '<div class="empty-state">Queue is empty. Add tracks below.</div>';
return;
}
let html = '<div class="queue-items">';
streamQueue.forEach((item, index) => {
if (item) {
html += `
<div class="queue-item" data-track-id="${item.id}">
<span class="queue-position">${index + 1}</span>
<div class="queue-track-info">
<div class="track-title">${item.title || 'Unknown'}</div>
<div class="track-artist">${item.artist || 'Unknown Artist'}</div>
</div>
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">Remove</button>
</div>
`;
}
});
html += '</div>';
container.innerHTML = html;
}
// Clear stream queue
async function clearStreamQueue() {
if (!confirm('Clear the entire stream queue? This will stop playback until new tracks are added.')) {
return;
}
try {
const response = await fetch('/api/asteroid/stream/queue/clear', {
method: 'POST'
});
const result = await response.json();
const data = result.data || result;
if (data.status === 'success') {
alert('Queue cleared successfully');
loadStreamQueue();
} else {
alert('Error clearing queue: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Error clearing queue:', error);
alert('Error clearing queue');
}
}
// Remove track from queue
async function removeFromQueue(trackId) {
try {
const response = await fetch('/api/asteroid/stream/queue/remove', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `track-id=${trackId}`
});
const result = await response.json();
const data = result.data || result;
if (data.status === 'success') {
loadStreamQueue();
} else {
alert('Error removing track: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Error removing track:', error);
alert('Error removing track');
}
}
// Add track to queue
async function addToQueue(trackId, position = 'end', showNotification = true) {
try {
const response = await fetch('/api/asteroid/stream/queue/add', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `track-id=${trackId}&position=${position}`
});
const result = await response.json();
const data = result.data || result;
if (data.status === 'success') {
// Only reload queue if we're in the queue management section
const queueContainer = document.getElementById('stream-queue-container');
if (queueContainer && queueContainer.offsetParent !== null) {
loadStreamQueue();
}
// Show brief success notification
if (showNotification) {
showToast('✓ Added to queue');
}
return true;
} else {
alert('Error adding track: ' + (data.message || 'Unknown error'));
return false;
}
} catch (error) {
console.error('Error adding track:', error);
alert('Error adding track');
return false;
}
}
// Simple toast notification
function showToast(message) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #00ff00;
color: #000;
padding: 12px 20px;
border-radius: 4px;
font-weight: bold;
z-index: 10000;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
// Add random tracks to queue
async function addRandomTracks() {
if (tracks.length === 0) {
alert('No tracks available. Please scan the library first.');
return;
}
const count = 10;
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, Math.min(count, tracks.length));
for (const track of selected) {
await addToQueue(track.id, 'end', false); // Don't show toast for each track
}
showToast(`✓ Added ${selected.length} random tracks to queue`);
}
// Search tracks for adding to queue
function searchTracksForQueue(event) {
clearTimeout(queueSearchTimeout);
const query = event.target.value.toLowerCase();
if (query.length < 2) {
document.getElementById('queue-track-results').innerHTML = '';
return;
}
queueSearchTimeout = setTimeout(() => {
const results = tracks.filter(track =>
(track.title && track.title.toLowerCase().includes(query)) ||
(track.artist && track.artist.toLowerCase().includes(query)) ||
(track.album && track.album.toLowerCase().includes(query))
).slice(0, 20); // Limit to 20 results
displayQueueSearchResults(results);
}, 300);
}
// Display search results for queue
function displayQueueSearchResults(results) {
const container = document.getElementById('queue-track-results');
if (results.length === 0) {
container.innerHTML = '<div class="empty-state">No tracks found</div>';
return;
}
let html = '<div class="search-results">';
results.forEach(track => {
html += `
<div class="search-result-item">
<div class="track-info">
<div class="track-title">${track.title || 'Unknown'}</div>
<div class="track-artist">${track.artist || 'Unknown'} - ${track.album || 'Unknown Album'}</div>
</div>
<div class="track-actions">
<button class="btn btn-sm btn-primary" onclick="addToQueue(${track.id}, 'end')">Add to End</button>
<button class="btn btn-sm btn-success" onclick="addToQueue(${track.id}, 'next')">Play Next</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
// Live stream info update
async function updateLiveStreamInfo() {
try {
const response = await fetch('/api/asteroid/icecast-status');
if (!response.ok) {
return;
}
const result = await response.json();
// Handle Radiance API response format
const data = result.data || result;
// Sources are nested in icestats
const sources = data.icestats?.source;
if (sources) {
const mainStream = Array.isArray(sources)
? sources.find(s => s.listenurl?.includes('/asteroid.aac') || s.listenurl?.includes('/asteroid.mp3'))
: sources;
if (mainStream && mainStream.title) {
const nowPlayingEl = document.getElementById('live-now-playing');
if (nowPlayingEl) {
const parts = mainStream.title.split(' - ');
const artist = parts[0] || 'Unknown';
const track = parts.slice(1).join(' - ') || 'Unknown';
nowPlayingEl.textContent = `${artist} - ${track}`;
}
}
}
} catch (error) {
console.error('Could not fetch stream info:', error);
}
}

View File

@ -18,7 +18,14 @@ document.addEventListener('DOMContentLoaded', function() {
loadPlaylists();
setupEventListeners();
updatePlayerDisplay();
updateVolume()
updateVolume();
// Setup live stream with reduced buffering
const liveAudio = document.getElementById('live-stream-audio');
if (liveAudio) {
// Reduce buffer to minimize delay
liveAudio.preload = 'none';
}
});
function setupEventListeners() {
@ -338,7 +345,6 @@ async function createPlaylist() {
});
const result = await response.json();
console.log('Create playlist result:', result);
if (result.status === 'success') {
alert(`Playlist "${name}" created successfully!`);
@ -377,7 +383,6 @@ async function saveQueueAsPlaylist() {
});
const createResult = await createResponse.json();
console.log('Create playlist result:', createResult);
if (createResult.status === 'success') {
// Wait a moment for database to update
@ -386,20 +391,16 @@ async function saveQueueAsPlaylist() {
// Get the new playlist ID by fetching playlists
const playlistsResponse = await fetch('/api/asteroid/playlists');
const playlistsResult = await playlistsResponse.json();
console.log('Playlists result:', playlistsResult);
if (playlistsResult.status === 'success' && playlistsResult.playlists.length > 0) {
// Find the playlist with matching name (most recent)
const newPlaylist = playlistsResult.playlists.find(p => p.name === name) ||
playlistsResult.playlists[playlistsResult.playlists.length - 1];
console.log('Found playlist:', newPlaylist);
// Add all tracks from queue to playlist
let addedCount = 0;
for (const track of playQueue) {
const trackId = track.id || (Array.isArray(track.id) ? track.id[0] : null);
console.log('Adding track to playlist:', track, 'ID:', trackId);
if (trackId) {
const addFormData = new FormData();
@ -412,7 +413,6 @@ async function saveQueueAsPlaylist() {
});
const addResult = await addResponse.json();
console.log('Add track result:', addResult);
if (addResult.status === 'success') {
addedCount++;
@ -441,12 +441,11 @@ async function loadPlaylists() {
const response = await fetch('/api/asteroid/playlists');
const result = await response.json();
console.log('Load playlists result:', result);
if (result.status === 'success') {
if (result.data && result.data.status === 'success') {
displayPlaylists(result.data.playlists || []);
} else if (result.status === 'success') {
displayPlaylists(result.playlists || []);
} else {
console.error('Error loading playlists:', result.message);
displayPlaylists([]);
}
} catch (error) {
@ -483,8 +482,6 @@ async function loadPlaylist(playlistId) {
const response = await fetch(`/api/asteroid/playlists/get?playlist-id=${playlistId}`);
const result = await response.json();
console.log('Load playlist result:', result);
if (result.status === 'success' && result.playlist) {
const playlist = result.playlist;
@ -577,7 +574,6 @@ async function updateLiveStream() {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
console.log('Live stream data:', result); // Debug log
// Handle RADIANCE API wrapper format
const data = result.data || result;
@ -595,13 +591,8 @@ async function updateLiveStream() {
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 update error:', error);

179
stream-control.lisp Normal file
View File

@ -0,0 +1,179 @@
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
(in-package :asteroid)
;;; Stream Queue Management
;;; The stream queue represents what will play on the main broadcast
(defvar *stream-queue* '() "List of track IDs queued for streaming")
(defvar *stream-history* '() "List of recently played track IDs")
(defvar *max-history-size* 50 "Maximum number of tracks to keep in history")
(defun get-stream-queue ()
"Get the current stream queue"
*stream-queue*)
(defun add-to-stream-queue (track-id &optional (position :end))
"Add a track to the stream queue at specified position (:end or :next)"
(case position
(:next (push track-id *stream-queue*))
(:end (setf *stream-queue* (append *stream-queue* (list track-id))))
(t (error "Position must be :next or :end")))
(regenerate-stream-playlist)
t)
(defun remove-from-stream-queue (track-id)
"Remove a track from the stream queue"
(setf *stream-queue* (remove track-id *stream-queue* :test #'equal))
(regenerate-stream-playlist)
t)
(defun clear-stream-queue ()
"Clear the entire stream queue"
(setf *stream-queue* '())
(regenerate-stream-playlist)
t)
(defun reorder-stream-queue (track-ids)
"Reorder the stream queue with a new list of track IDs"
(setf *stream-queue* track-ids)
(regenerate-stream-playlist)
t)
(defun add-playlist-to-stream-queue (playlist-id)
"Add all tracks from a playlist to the stream queue"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids-str (if (listp track-ids-raw)
(first track-ids-raw)
track-ids-raw))
(track-ids (if (and track-ids-str
(stringp track-ids-str)
(not (string= track-ids-str "")))
(mapcar #'parse-integer
(cl-ppcre:split "," track-ids-str))
nil)))
(dolist (track-id track-ids)
(add-to-stream-queue track-id :end))
t))))
;;; M3U Playlist Generation
(defun get-track-file-path (track-id)
"Get the file path for a track by ID"
(let ((track (get-track-by-id track-id)))
(when track
(let ((file-path (gethash "file-path" track)))
(if (listp file-path)
(first file-path)
file-path)))))
(defun convert-to-docker-path (host-path)
"Convert host file path to Docker container path"
;; Replace /home/glenn/Projects/Code/asteroid/music/library/ with /app/music/
(let ((library-prefix "/home/glenn/Projects/Code/asteroid/music/library/"))
(if (and (stringp host-path)
(>= (length host-path) (length library-prefix))
(string= host-path library-prefix :end1 (length library-prefix)))
(concatenate 'string "/app/music/"
(subseq host-path (length library-prefix)))
host-path)))
(defun generate-m3u-playlist (track-ids output-path)
"Generate an M3U playlist file from a list of track IDs"
(with-open-file (stream output-path
:direction :output
:if-exists :supersede
:if-does-not-exist :create)
(format stream "#EXTM3U~%")
(dolist (track-id track-ids)
(let ((file-path (get-track-file-path track-id)))
(when file-path
(let ((docker-path (convert-to-docker-path file-path)))
(format stream "#EXTINF:0,~%")
(format stream "~a~%" docker-path))))))
t)
(defun regenerate-stream-playlist ()
"Regenerate the main stream playlist from the current queue"
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
(asdf:system-source-directory :asteroid))))
(if (null *stream-queue*)
;; If queue is empty, generate from all tracks (fallback)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(generate-m3u-playlist
(mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
all-tracks)
playlist-path))
;; Generate from queue
(generate-m3u-playlist *stream-queue* playlist-path))))
(defun export-playlist-to-m3u (playlist-id output-path)
"Export a user playlist to an M3U file"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids-str (if (listp track-ids-raw)
(first track-ids-raw)
track-ids-raw))
(track-ids (if (and track-ids-str
(stringp track-ids-str)
(not (string= track-ids-str "")))
(mapcar #'parse-integer
(cl-ppcre:split "," track-ids-str))
nil)))
(generate-m3u-playlist track-ids output-path)))))
;;; Stream History Management
(defun add-to-stream-history (track-id)
"Add a track to the stream history"
(push track-id *stream-history*)
;; Keep history size limited
(when (> (length *stream-history*) *max-history-size*)
(setf *stream-history* (subseq *stream-history* 0 *max-history-size*)))
t)
(defun get-stream-history (&optional (count 10))
"Get recent stream history (default 10 tracks)"
(subseq *stream-history* 0 (min count (length *stream-history*))))
;;; Smart Queue Building
(defun build-smart-queue (genre &optional (count 20))
"Build a smart queue based on genre"
(let ((tracks (db:select "tracks" (db:query :all))))
;; For now, just add random tracks
;; TODO: Implement genre filtering when we have genre metadata
(let ((track-ids (mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
tracks)))
(setf *stream-queue* (subseq (alexandria:shuffle track-ids)
0
(min count (length track-ids))))
(regenerate-stream-playlist)
*stream-queue*)))
(defun build-queue-from-artist (artist-name &optional (count 20))
"Build a queue from tracks by a specific artist"
(let ((tracks (db:select "tracks" (db:query :all))))
(let ((matching-tracks
(remove-if-not
(lambda (track)
(let ((artist (gethash "artist" track)))
(when artist
(let ((artist-str (if (listp artist) (first artist) artist)))
(search artist-name artist-str :test #'char-equal)))))
tracks)))
(let ((track-ids (mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
matching-tracks)))
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
(regenerate-stream-playlist)
*stream-queue*))))

View File

@ -61,8 +61,17 @@
(defun track-exists-p (file-path)
"Check if a track with the given file path already exists in the database"
;; Try direct query first
(let ((existing (db:select "tracks" (db:query (:= "file-path" file-path)))))
(> (length existing) 0)))
(if (> (length existing) 0)
t
;; If not found, search manually (file-path might be stored as list)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(some (lambda (track)
(let ((stored-path (gethash "file-path" track)))
(or (equal stored-path file-path)
(and (listp stored-path) (equal (first stored-path) file-path)))))
all-tracks)))))
(defun insert-track-to-database (metadata)
"Insert track metadata into database if it doesn't already exist"
@ -73,9 +82,7 @@
;; Check if track already exists
(let ((file-path (getf metadata :file-path)))
(if (track-exists-p file-path)
(progn
(format t "Track already exists, skipping: ~a~%" file-path)
nil)
nil
(progn
(db:insert "tracks"
(list (list "title" (getf metadata :title))
@ -91,34 +98,27 @@
(defun scan-music-library (&optional (directory *music-library-path*))
"Scan music library directory and add tracks to database"
(format t "Scanning music library: ~a~%" directory)
(let ((audio-files (scan-directory-for-music-recursively directory))
(added-count 0)
(skipped-count 0))
(format t "Found ~a audio files to process~%" (length audio-files))
(dolist (file audio-files)
(let ((metadata (extract-metadata-with-taglib file)))
(when metadata
(handler-case
(if (insert-track-to-database metadata)
(progn
(incf added-count)
(format t "Added: ~a~%" (getf metadata :file-path)))
(incf added-count)
(incf skipped-count))
(error (e)
(format t "Error adding ~a: ~a~%" file e))))))
(format t "Library scan complete. Added ~a new tracks, skipped ~a existing tracks.~%"
added-count skipped-count)
added-count))
;; Initialize music directory structure
(defun ensure-music-directories ()
"Create music directory structure if it doesn't exist"
(let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid))))
(defun initialize-music-directories (&optional (base-dir *music-library-path*))
"Create necessary music directories if they don't exist"
(progn
(ensure-directories-exist (merge-pathnames "library/" base-dir))
(ensure-directories-exist (merge-pathnames "incoming/" base-dir))
(ensure-directories-exist (merge-pathnames "temp/" base-dir))
(format t "Music directories initialized at ~a~%" base-dir)))
(ensure-directories-exist (merge-pathnames "temp/" base-dir))))
;; Simple file copy endpoint for manual uploads
(define-page copy-files #@"/admin/copy-files" ()

19
stream-queue.m3u Normal file
View File

@ -0,0 +1,19 @@
#EXTM3U
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
#EXTINF:0,
/app/music/Kraftwerk/1978 - The Man-Machine \[2009 Digital Remaster]/02 - Spacelab.flac
#EXTINF:0,
/app/music/Kraftwerk/1981 - Computer World \[2009 Digital Remaster]/03 - Numbers.flac
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
#EXTINF:0,
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
#EXTINF:0,
/app/music/This Mortal Coil/1984 - It'll End In Tears/09 - Barramundi.flac
#EXTINF:0,
/app/music/Underworld/1996 - Second Toughest In The Infants/04. Underworld - Rowla.flac
#EXTINF:0,
/app/music/This Mortal Coil/1984 - It'll End In Tears/10 - Dreams Made Flesh.flac
#EXTINF:0,
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac

View File

@ -107,6 +107,41 @@
</div>
</div>
<!-- Live Stream Monitor -->
<div class="admin-section">
<h2>📻 Live Stream Monitor</h2>
<div class="live-stream-monitor">
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
<audio id="live-stream-audio" controls style="width: 100%; max-width: 600px;">
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
Your browser does not support the audio element.
</audio>
</div>
</div>
<!-- Stream Queue Management -->
<div class="admin-section">
<h2>🎵 Stream Queue Management</h2>
<p>Manage the live stream playback queue. Changes take effect within 5-10 seconds.</p>
<div class="queue-controls">
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
</div>
<div id="stream-queue-container" class="queue-list">
<div class="loading">Loading queue...</div>
</div>
<div class="queue-actions">
<h3>Add Tracks to Queue</h3>
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
<div id="queue-track-results" class="track-results"></div>
</div>
</div>
<!-- Player Control -->
<div class="admin-section">
<h2>Player Control</h2>