Compare commits
4 Commits
90bb9a1650
...
cc32ee1d94
| Author | SHA1 | Date |
|---|---|---|
|
|
cc32ee1d94 | |
|
|
08c88baa12 | |
|
|
3b7ed635f0 | |
|
|
366e481867 |
|
|
@ -38,5 +38,6 @@
|
|||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "auth-routes")
|
||||
(:file "asteroid")))
|
||||
|
|
|
|||
110
asteroid.lisp
110
asteroid.lisp
|
|
@ -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" ()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "📡 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/"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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*))))
|
||||
|
|
@ -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 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" ()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue