Compare commits

...

4 Commits

Author SHA1 Message Date
Glenn Thompson 6e23efe1e4 Fix listener count and recently-played rendering
- Use total listener count across all mounts instead of per-mount
  (asteroid.lisp icecast-status API, stream-harmony.lisp now-playing)
- Fix recently-played.lisp: equal -> = for ParenScript string comparison
- Remove non-existent asteroid-shuffle.mp3 mount from recently-played JS
- Map all mount references to existing asteroid.mp3/asteroid.aac
2026-03-03 23:25:23 +03:00
Glenn Thompson fd1bc504a5 cl-streamer integration fixes: CORS, reconnect, stream config
Server-side fixes (stream-server.lisp):
- Add CORS preflight (OPTIONS) request handler for browser crossorigin audio
- AAC clients start from current buffer position instead of burst to avoid
  ADTS frame alignment issues that caused browser decode errors
- Upgrade client stream error logging from debug to warn for diagnostics
- Add send-cors-preflight function with proper Access-Control headers

Frontend fixes (stream-player.lisp):
- Rewrite reconnect-stream to reuse existing audio element instead of
  creating a new one, preserving browser user gesture context and preventing
  NotAllowedError on autoplay after reconnect
- Unify stream config: both curated and shuffle channels use same mount
  points (asteroid.mp3/asteroid.aac) since cl-streamer has a single pipeline
- Remove non-existent /asteroid-shuffle.mp3 mount reference that caused
  404s and broken pipe cascade when switching to shuffle channel
- Map :low quality to same MP3 mount (asteroid-low.mp3 not yet available)

Note: Channel selector preserved for future multi-stream support.
Recently-played API works correctly; frontend rendering to investigate separately.
2026-03-03 23:17:01 +03:00
Glenn Thompson 77458467c4 Fix integration: CORS, auto-start, mount names, Icecast bypass
CORS fix (icy-protocol.lisp):
- Add Access-Control-Allow-Origin: * to stream response headers
- Browser audio player can now connect cross-origin (port 8080 -> 8000)

Auto-start (asteroid.lisp -main):
- Start cl-streamer pipeline automatically on boot
- Load stream-queue.m3u and begin playback immediately
- Wrapped in handler-case so streaming failure doesn't block web server

Mount names (stream-harmony.lisp):
- Renamed /stream.mp3 -> /asteroid.mp3, /stream.aac -> /asteroid.aac
- Matches existing frontend URLs, zero template changes needed

Icecast bypass (asteroid.lisp, listener-stats.lisp):
- Front page uses get-now-playing-stats instead of icecast-now-playing
- check-icecast-status returns cl-streamer status when pipeline is active
- check-liquidsoap-status returns N/A when using cl-streamer
- asteroid/icecast-status API returns cl-streamer data directly
- poll-and-store-stats uses cl-streamer listener counts directly
- Eliminates hanging HTTP requests to port 8000 for Icecast XML

Tested: full browser streaming working end-to-end
2026-03-03 22:29:21 +03:00
Glenn Thompson dad1418bf8 Integrate cl-streamer into Asteroid Radio (replaces Icecast + Liquidsoap)
New files:
- stream-harmony.lisp: Bridge between cl-streamer pipeline and Asteroid app
  - start-harmony-streaming / stop-harmony-streaming lifecycle
  - on-harmony-track-change callback: feeds recently-played, DB track lookup
  - harmony-now-playing: returns same alist format as icecast-now-playing
  - harmony-load-playlist: loads M3U, converts Docker paths, feeds queue
  - harmony-skip-track / harmony-get-status

Pipeline control (harmony-backend.lisp):
- Add pipeline-current-track, pipeline-on-track-change callback
- Add pipeline-skip, pipeline-queue-files, pipeline-get-queue, pipeline-clear-queue
- play-list now supports skip flag, queue consumption, loop-queue mode
- notify-track-change fires callback after crossfade completes

Graceful fallback - all touch points check *harmony-pipeline*:
- frontend-partials.lisp: now-playing endpoints try Harmony first, fall back to Icecast
- asteroid.lisp: admin APIs (status/skip/reload/restart) try Harmony first
- playlist-scheduler.lisp: load-scheduled-playlist tries Harmony first
- asteroid.asd: added cl-streamer subsystem dependencies

Docker scripts updated:
- start.sh / stop.sh: only start/stop postgres (cl-streamer replaces streaming)
2026-03-03 21:27:29 +03:00
13 changed files with 650 additions and 291 deletions

View File

@ -29,6 +29,11 @@
:bordeaux-threads
:drakma
:cl-cron
;; CL-Streamer (replaces Icecast + Liquidsoap)
:cl-streamer
:cl-streamer/encoder
:cl-streamer/aac-encoder
:cl-streamer/harmony
;; radiance interfaces
:i-log4cl
:i-postmodern
@ -64,6 +69,7 @@
(:file "user-management")
(:file "playlist-management")
(:file "stream-control")
(:file "stream-harmony")
(:file "playlist-scheduler")
(:file "listener-stats")
(:file "user-profile")

View File

@ -442,11 +442,13 @@
;; Load into in-memory queue
(let ((count (load-queue-from-m3u-file))
(channel-name (get-curated-channel-name)))
;; Skip current track to trigger crossfade to new playlist
;; Skip/switch to new playlist
(if *harmony-pipeline*
(harmony-load-playlist playlist-path)
(handler-case
(liquidsoap-command "stream-queue_m3u.skip")
(error (e)
(format *error-output* "Warning: Could not skip track: ~a~%" e)))
(format *error-output* "Warning: Could not skip track: ~a~%" e))))
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded playlist: ~a" name))
("count" . ,count)
@ -568,40 +570,66 @@
(error () seconds-str)))
(define-api asteroid/liquidsoap/status () ()
"Get Liquidsoap status including uptime and current track"
"Get stream status - uses Harmony pipeline when available, falls back to Liquidsoap"
(require-role :admin)
(with-error-handling
(if *harmony-pipeline*
(let ((status (harmony-get-status)))
(api-output `(("status" . "success")
("backend" . "harmony")
("uptime" . "n/a")
("metadata" . ,(getf status :current-track))
("remaining" . "n/a")
("listeners" . ,(getf status :listeners))
("queue_length" . ,(getf status :queue-length)))))
(let ((uptime (liquidsoap-command "uptime"))
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
(api-output `(("status" . "success")
("backend" . "liquidsoap")
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
("remaining" . ,(format-remaining-time
(string-trim '(#\Space #\Newline #\Return) remaining-raw))))))))
(string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))))
(define-api asteroid/liquidsoap/skip () ()
"Skip the current track in Liquidsoap"
"Skip the current track"
(require-role :admin)
(with-error-handling
(if *harmony-pipeline*
(progn
(harmony-skip-track)
(api-output `(("status" . "success")
("message" . "Track skipped (Harmony)"))))
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
(api-output `(("status" . "success")
("message" . "Track skipped")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
(define-api asteroid/liquidsoap/reload () ()
"Force Liquidsoap to reload the playlist"
"Force playlist reload"
(require-role :admin)
(with-error-handling
(if *harmony-pipeline*
(let* ((playlist-path (get-stream-queue-path))
(count (harmony-load-playlist playlist-path)))
(api-output `(("status" . "success")
("message" . ,(format nil "Playlist reloaded (~A tracks via Harmony)" count)))))
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
(api-output `(("status" . "success")
("message" . "Playlist reloaded")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
(define-api asteroid/liquidsoap/restart () ()
"Restart the Liquidsoap Docker container"
"Restart the streaming backend"
(require-role :admin)
(with-error-handling
(if *harmony-pipeline*
(progn
(stop-harmony-streaming)
(start-harmony-streaming)
(api-output `(("status" . "success")
("message" . "Harmony pipeline restarted"))))
(let ((result (uiop:run-program
"docker restart asteroid-liquidsoap"
:output :string
@ -609,7 +637,7 @@
:ignore-error-status t)))
(api-output `(("status" . "success")
("message" . "Liquidsoap container restarting")
("result" . ,result))))))
("result" . ,result)))))))
(define-api asteroid/icecast/restart () ()
"Restart the Icecast Docker container"
@ -825,7 +853,7 @@
"Main front page"
;; Register this visitor for geo stats (captures real IP from X-Forwarded-For)
(register-web-listener)
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
(let ((now-playing-stats (get-now-playing-stats)))
(clip:process-to-string
(load-template "front-page")
:title "ASTEROID RADIO"
@ -982,16 +1010,22 @@
;; Status check functions
(defun check-icecast-status ()
"Check if Icecast server is running and accessible"
"Check if streaming backend is running.
Uses Harmony pipeline status when available, falls back to Icecast HTTP check."
(if *harmony-pipeline*
"🟢 Running (cl-streamer)"
(handler-case
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
:want-stream nil
:connection-timeout 2)))
(if response "🟢 Running" "🔴 Not Running"))
(error () "🔴 Not Running")))
(error () "🔴 Not Running"))))
(defun check-liquidsoap-status ()
"Check if Liquidsoap is running via Docker"
"Check if Liquidsoap is running via Docker.
Returns N/A when using cl-streamer."
(if *harmony-pipeline*
"⚪ N/A (using cl-streamer)"
(handler-case
(let* ((output (with-output-to-string (stream)
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
@ -1000,7 +1034,7 @@
:ignore-error-status t)))
(running-p (search "Up" output)))
(if running-p "🟢 Running" "🔴 Not Running"))
(error () "🔴 Not Running")))
(error () "🔴 Not Running"))))
;; Admin page (requires authentication)
(define-page admin #@"/admin" ()
@ -1348,10 +1382,20 @@
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("stream-status" . "live"))))
;; Live stream status from Icecast
;; Live stream status
(define-api-with-limit asteroid/icecast-status () ()
"Get live status from Icecast server"
"Get live stream status. Uses Harmony pipeline when available, falls back to Icecast."
(with-error-handling
(if *harmony-pipeline*
;; Return status from cl-streamer directly
(let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
(title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
(listeners (or (cl-streamer:get-listener-count) 0)))
(api-output
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("title" . ,title)
("listeners" . ,listeners))))))))
;; Fallback: poll Icecast XML
(let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
(response (drakma:http-request icecast-url
:want-stream nil
@ -1360,10 +1404,9 @@
(let ((xml-string (if (stringp response)
response
(babel:octets-to-string response :encoding :utf-8))))
;; Simple XML parsing to extract source information
;; Look for <source mount="/asteroid.mp3"> sections and extract title, listeners, etc.
(multiple-value-bind (match-start match-end)
(cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)
(declare (ignore match-end))
(if match-start
(let* ((source-section (subseq xml-string match-start
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
@ -1372,17 +1415,15 @@
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
;; Return JSON in format expected by frontend
(api-output
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("title" . ,title)
("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
;; No source found, return empty
(api-output
`(("icestats" . (("source" . nil))))))))
(api-output
`(("error" . "Could not connect to Icecast server"))
:status 503)))))
:status 503))))))
;;; Listener Statistics API Endpoints
@ -1526,4 +1567,23 @@
;; TODO: Add auto-scan on startup once database timing issues are resolved
;; For now, use the "Scan Library" button in the admin interface
;; Start cl-streamer audio pipeline (replaces Icecast + Liquidsoap)
(format t "Starting cl-streamer audio pipeline...~%")
(handler-case
(progn
(start-harmony-streaming)
;; Load the current playlist and start playing
(let ((playlist-path (get-stream-queue-path)))
(when (probe-file playlist-path)
(let ((file-list (m3u-to-file-list playlist-path)))
(when file-list
(cl-streamer/harmony:play-list *harmony-pipeline* file-list
:crossfade-duration 3.0)
(format t "~A tracks loaded from stream-queue.m3u~%" (length file-list))))))
(format t "📡 Stream: ~a/asteroid.mp3~%" *stream-base-url*)
(format t "📡 Stream: ~a/asteroid.aac~%" *stream-base-url*))
(error (e)
(format t "⚠️ Could not start streaming: ~a~%" e)
(format t " (Web server will run without streaming)~%")))
(run-server))

View File

@ -10,7 +10,17 @@
#:play-file
#:play-list
#:pipeline-server
#:make-streaming-server))
#:make-streaming-server
;; Track state & control
#:pipeline-current-track
#:pipeline-on-track-change
#:pipeline-skip
#:pipeline-queue-files
#:pipeline-get-queue
#:pipeline-clear-queue
;; Metadata helpers
#:read-audio-metadata
#:format-display-title))
(in-package #:cl-streamer/harmony)
@ -94,7 +104,20 @@
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
(channels :initarg :channels :accessor pipeline-channels :initform 2)
(running :initform nil :accessor pipeline-running-p)))
(running :initform nil :accessor pipeline-running-p)
;; Track state
(current-track :initform nil :accessor pipeline-current-track
:documentation "Plist of current track: (:title :artist :album :file :display-title)")
(on-track-change :initarg :on-track-change :initform nil
:accessor pipeline-on-track-change
:documentation "Callback (lambda (pipeline track-info)) called on track change")
;; Playlist queue & skip control
(file-queue :initform nil :accessor pipeline-file-queue
:documentation "List of file entries to play after current playlist")
(queue-lock :initform (bt:make-lock "pipeline-queue-lock")
:reader pipeline-queue-lock)
(skip-flag :initform nil :accessor pipeline-skip-flag
:documentation "Set to T to skip the current track")))
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
(sample-rate 44100) (channels 2))
@ -156,6 +179,43 @@
(log:info "Audio pipeline stopped")
pipeline)
;;; ---- Pipeline Control ----
(defun pipeline-skip (pipeline)
"Skip the current track. The play-list loop will detect this and advance."
(setf (pipeline-skip-flag pipeline) t)
(log:info "Skip requested"))
(defun pipeline-queue-files (pipeline file-entries &key (position :end))
"Add file entries to the pipeline queue.
Each entry is a string (path) or plist (:file path :title title).
POSITION is :end (append) or :next (prepend)."
(bt:with-lock-held ((pipeline-queue-lock pipeline))
(case position
(:next (setf (pipeline-file-queue pipeline)
(append file-entries (pipeline-file-queue pipeline))))
(t (setf (pipeline-file-queue pipeline)
(append (pipeline-file-queue pipeline) file-entries)))))
(log:info "Queued ~A files (~A)" (length file-entries) position))
(defun pipeline-get-queue (pipeline)
"Get the current file queue (copy)."
(bt:with-lock-held ((pipeline-queue-lock pipeline))
(copy-list (pipeline-file-queue pipeline))))
(defun pipeline-clear-queue (pipeline)
"Clear the file queue."
(bt:with-lock-held ((pipeline-queue-lock pipeline))
(setf (pipeline-file-queue pipeline) nil))
(log:info "Queue cleared"))
(defun pipeline-pop-queue (pipeline)
"Pop the next entry from the file queue (internal use)."
(bt:with-lock-held ((pipeline-queue-lock pipeline))
(pop (pipeline-file-queue pipeline))))
;;; ---- Metadata ----
(defun read-audio-metadata (file-path)
"Read metadata (artist, title, album) from an audio file using taglib.
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
@ -190,6 +250,15 @@
(dolist (output (drain-outputs (pipeline-drain pipeline)))
(cl-streamer:set-now-playing (cdr output) display-title)))
(defun notify-track-change (pipeline track-info)
"Update pipeline state and fire the on-track-change callback."
(setf (pipeline-current-track pipeline) track-info)
(when (pipeline-on-track-change pipeline)
(handler-case
(funcall (pipeline-on-track-change pipeline) pipeline track-info)
(error (e)
(log:warn "Track change callback error: ~A" e)))))
(defun play-file (pipeline file-path &key (mixer :music) title (on-end :free)
(update-metadata t))
"Play an audio file through the pipeline.
@ -202,12 +271,19 @@
(let* ((path (pathname file-path))
(server (pipeline-harmony-server pipeline))
(harmony:*server* server)
(display-title (format-display-title path title)))
(tags (read-audio-metadata path))
(display-title (format-display-title path title))
(track-info (list :file (namestring path)
:display-title display-title
:artist (getf tags :artist)
:title (getf tags :title)
:album (getf tags :album))))
(when update-metadata
(update-all-mounts-metadata pipeline display-title))
(update-all-mounts-metadata pipeline display-title)
(notify-track-change pipeline track-info))
(let ((voice (harmony:play path :mixer mixer :on-end on-end)))
(log:info "Now playing: ~A" display-title)
(values voice display-title))))
(values voice display-title track-info))))
(defun voice-remaining-seconds (voice)
"Return estimated seconds remaining for a voice, or NIL if unknown."
@ -231,28 +307,47 @@
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
(sleep step-time))))
(defun next-entry (pipeline file-list-ref)
"Get the next entry to play: from file-list first, then from the queue.
FILE-LIST-REF is a cons cell whose car is the remaining file list.
Returns an entry or NIL if nothing to play."
(or (pop (car file-list-ref))
(pipeline-pop-queue pipeline)))
(defun play-list (pipeline file-list &key (crossfade-duration 3.0)
(fade-in 2.0)
(fade-out 2.0))
(fade-out 2.0)
(loop-queue nil))
"Play a list of file paths sequentially through the pipeline.
Each entry can be a string (path) or a plist (:file path :title title).
CROSSFADE-DURATION is how early to start the next track (seconds).
FADE-IN/FADE-OUT control the volume ramp durations.
Both voices play simultaneously through the mixer during crossfade."
Both voices play simultaneously through the mixer during crossfade.
When LOOP-QUEUE is T, waits for new queue entries instead of stopping."
(bt:make-thread
(lambda ()
(let ((prev-voice nil))
(loop for entry in file-list
for idx from 0
while (pipeline-running-p pipeline)
do (multiple-value-bind (path title)
(let ((prev-voice nil)
(idx 0)
(remaining-list (list (copy-list file-list))))
(loop while (pipeline-running-p pipeline)
for entry = (next-entry pipeline remaining-list)
do (cond
;; No entry and loop mode: wait for queue
((and (null entry) loop-queue)
(sleep 1))
;; No entry: done
((null entry)
(return))
;; Play the entry
(t
(multiple-value-bind (path title)
(if (listp entry)
(values (getf entry :file) (getf entry :title))
(values entry nil))
(handler-case
(let* ((server (pipeline-harmony-server pipeline))
(harmony:*server* server))
(multiple-value-bind (voice display-title)
(multiple-value-bind (voice display-title track-info)
(play-file pipeline path :title title
:on-end :disconnect
:update-metadata (null prev-voice))
@ -260,7 +355,6 @@
;; If this isn't the first track, crossfade
(when (and prev-voice (> idx 0))
(setf (mixed:volume voice) 0.0)
;; Fade in new voice and fade out old voice in parallel
(let ((fade-thread
(bt:make-thread
(lambda ()
@ -269,12 +363,15 @@
:name "cl-streamer-fadeout")))
(volume-ramp voice 1.0 fade-in)
(bt:join-thread fade-thread))
;; Now the crossfade is done, update metadata
(update-all-mounts-metadata pipeline display-title))
;; Wait for track to approach its end
;; Crossfade done — now update metadata & notify
(update-all-mounts-metadata pipeline display-title)
(notify-track-change pipeline track-info))
;; Wait for track to approach its end (or skip)
(setf (pipeline-skip-flag pipeline) nil)
(sleep 0.5)
(loop while (and (pipeline-running-p pipeline)
(not (mixed:done-p voice)))
(not (mixed:done-p voice))
(not (pipeline-skip-flag pipeline)))
for remaining = (voice-remaining-seconds voice)
when (and remaining
(<= remaining crossfade-duration)
@ -282,13 +379,19 @@
do (setf prev-voice voice)
(return)
do (sleep 0.1))
;; Handle skip
(when (pipeline-skip-flag pipeline)
(setf (pipeline-skip-flag pipeline) nil)
(setf prev-voice voice)
(log:info "Skipping current track"))
;; If track ended naturally (no crossfade), clean up
(when (mixed:done-p voice)
(harmony:stop voice)
(setf prev-voice nil)))))
(setf prev-voice nil))
(incf idx))))
(error (e)
(log:warn "Error playing ~A: ~A" path e)
(sleep 1)))))
(sleep 1)))))))
;; Clean up last voice
(when prev-voice
(let ((harmony:*server* (pipeline-harmony-server pipeline)))

View File

@ -50,6 +50,8 @@
(format stream "icy-br: ~A~C~C" bitrate #\Return #\Linefeed)
(when metaint
(format stream "icy-metaint: ~A~C~C" metaint #\Return #\Linefeed))
(format stream "Access-Control-Allow-Origin: *~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Headers: Origin, Accept, Content-Type, Icy-MetaData~C~C" #\Return #\Linefeed)
(format stream "Cache-Control: no-cache, no-store~C~C" #\Return #\Linefeed)
(format stream "Connection: close~C~C" #\Return #\Linefeed)
(format stream "~C~C" #\Return #\Linefeed)

View File

@ -122,7 +122,13 @@
:external-format :latin-1)))
(handler-case
(let* ((request-line (read-line stream))
(headers (read-http-headers stream)))
(headers (read-http-headers stream))
(method (first (split-sequence:split-sequence #\Space request-line))))
;; Handle CORS preflight
(when (string-equal method "OPTIONS")
(send-cors-preflight stream)
(ignore-errors (usocket:socket-close client-socket))
(return-from handle-client))
(multiple-value-bind (path wants-meta)
(parse-icy-request request-line headers)
(let ((mount (gethash path (server-mounts server))))
@ -178,8 +184,13 @@
(stream (client-stream client))
(chunk-size 4096)
(chunk (make-array chunk-size :element-type '(unsigned-byte 8))))
;; Start from burst position for fast playback
(setf (client-read-pos client) (buffer-burst-start buffer))
;; For MP3, burst recent data for fast playback start.
;; For AAC, start from current position — AAC requires ADTS frame alignment
;; and burst data from mid-stream causes browser decode errors.
(setf (client-read-pos client)
(if (string= (mount-content-type mount) "audio/aac")
(buffer-current-pos buffer)
(buffer-burst-start buffer)))
(loop while (client-active-p client)
do (multiple-value-bind (bytes-read new-pos)
(buffer-read-from buffer (client-read-pos client) chunk)
@ -195,7 +206,8 @@
(write-sequence chunk stream :end bytes-read))
(force-output stream))
(error (e)
(log:debug "Client stream error: ~A" e)
(log:warn "Client stream error on ~A: ~A"
(mount-path mount) e)
(setf (client-active-p client) nil)
(return)))))))))
@ -221,6 +233,16 @@
(incf (client-bytes-since-meta client) bytes-remaining)
(setf pos length)))))))
(defun send-cors-preflight (stream)
"Send a CORS preflight response for OPTIONS requests."
(format stream "HTTP/1.1 204 No Content~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Origin: *~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Methods: GET, OPTIONS~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Headers: Origin, Accept, Content-Type, Icy-MetaData, Range~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Max-Age: 86400~C~C" #\Return #\Linefeed)
(format stream "~C~C" #\Return #\Linefeed)
(force-output stream))
(defun send-404 (stream path)
"Send a 404 response for unknown mount points."
(format stream "HTTP/1.1 404 Not Found~C~C" #\Return #\Linefeed)

View File

@ -14,9 +14,10 @@ if ! docker info > /dev/null 2>&1; then
exit 1
fi
# Start services
echo "🔧 Starting services..."
docker compose up -d
# Start services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
echo "🔧 Starting postgres..."
docker compose up -d postgres
# docker compose up -d # Uncomment to start all services (Icecast + Liquidsoap)
# Wait and show status
sleep 3
@ -25,8 +26,10 @@ echo "📊 Service Status:"
docker compose ps
echo ""
echo "🎵 Asteroid Radio is now streaming!"
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/"
echo "🎵 Asteroid Radio database is ready!"
echo "📡 Streaming is handled by cl-streamer (start from Lisp REPL)"
# Legacy Icecast URLs (no longer used):
# 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/"

View File

@ -5,8 +5,9 @@
echo "🛑 Stopping Asteroid Radio Docker Services..."
# Stop services
docker compose down
# Stop services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
docker compose down postgres
# docker compose down # Uncomment to stop all services
# if we really need to clean everything and start fresh, run the
# following commands:

View File

@ -93,23 +93,22 @@
(:track-id . ,(find-track-by-title title))
(:favorite-count . ,(or (get-track-favorite-count title) 1))))))))
(defun get-now-playing-stats (&optional (mount "asteroid.mp3"))
"Get now-playing stats from Harmony pipeline, falling back to Icecast.
Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count."
(or (harmony-now-playing mount)
(icecast-now-playing *stream-base-url* mount)))
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1)
"Get Partial HTML with live status from Icecast server.
"Get Partial HTML with live now-playing status.
Optional MOUNT parameter specifies which stream to get metadata from.
Always polls both streams to keep recently played lists updated."
Uses Harmony pipeline when available, falls back to Icecast."
(with-error-handling
(let* ((mount-name (or mount "asteroid.mp3"))
;; Always poll both streams to keep recently played lists updated
(dummy-curated (when (not (string= mount-name "asteroid.mp3"))
(icecast-now-playing *stream-base-url* "asteroid.mp3")))
(dummy-shuffle (when (not (string= mount-name "asteroid-shuffle.mp3"))
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats
(let* ((title (cdr (assoc :title now-playing-stats)))
(favorite-count (or (get-track-favorite-count title) 0)))
;; TODO: it should be able to define a custom api-output for this
;; (api-output <clip-parser> :format "html"))
(setf (header "Content-Type") "text/html")
(clip:process-to-string
(load-template "partial/now-playing")
@ -128,7 +127,7 @@
Optional MOUNT parameter specifies which stream to get metadata from."
(with-error-handling
(let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats
(progn
(setf (header "Content-Type") "text/plain")
@ -144,7 +143,7 @@
(register-web-listener)
(with-error-handling
(let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats
(let* ((title (cdr (assoc :title now-playing-stats)))
(favorite-count (or (get-track-favorite-count title) 0))

View File

@ -476,7 +476,16 @@
location-counts)))
(defun poll-and-store-stats ()
"Single poll iteration: fetch stats and store"
"Single poll iteration: fetch stats and store.
Uses cl-streamer listener counts when Harmony is running, falls back to Icecast."
(if *harmony-pipeline*
;; Get listener counts directly from cl-streamer
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
(let ((listeners (cl-streamer:get-listener-count mount)))
(when (and listeners (> listeners 0))
(store-listener-snapshot mount listeners)
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
;; Fallback: poll Icecast
(let ((stats (fetch-icecast-stats)))
(when stats
(let ((sources (parse-icecast-sources stats)))
@ -485,7 +494,7 @@
(listeners (getf source :listeners)))
(when mount
(store-listener-snapshot mount listeners)
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners)))))))
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
;; Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)
(collect-geo-stats-from-web-listeners))

View File

@ -27,12 +27,10 @@
(defun get-current-mount-for-recently-played ()
(let ((channel (get-current-channel))
(quality (get-current-quality)))
(if (= channel "shuffle")
"asteroid-shuffle.mp3"
(cond
((= quality "low") "asteroid-low.mp3")
((= quality "low") "asteroid.mp3")
((= quality "mp3") "asteroid.mp3")
(t "asteroid.aac")))))
(t "asteroid.aac"))))
;; Update recently played tracks display
(defun update-recently-played ()
@ -43,7 +41,7 @@
(then (lambda (result)
;; Radiance wraps API responses in a data envelope
(let ((data (or (ps:@ result data) result)))
(if (and (equal (ps:@ data status) "success")
(if (and (= (ps:@ data status) "success")
(ps:@ data tracks)
(> (ps:@ data tracks length) 0))
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))

View File

@ -149,9 +149,10 @@
;; ========================================
;; Get stream configuration for a given channel and quality
;; Curated channel has multiple quality options, shuffle has only one
;; With cl-streamer, both channels use the same stream mounts -
;; channel switching loads a different playlist server-side
(defun get-stream-config (stream-base-url channel quality)
(let ((curated-config (ps:create
(let ((config (ps:create
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:format "AAC 96kbps Stereo"
@ -160,17 +161,11 @@
:type "audio/mpeg"
:format "MP3 128kbps Stereo"
:mount "asteroid.mp3")
:low (ps:create :url (+ stream-base-url "/asteroid-low.mp3")
:low (ps:create :url (+ stream-base-url "/asteroid.mp3")
:type "audio/mpeg"
:format "MP3 64kbps Stereo"
:mount "asteroid-low.mp3")))
(shuffle-config (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3")
:type "audio/mpeg"
:format "Shuffle MP3 96kbps"
:mount "asteroid-shuffle.mp3")))
(if (= channel "shuffle")
shuffle-config
(ps:getprop curated-config quality))))
:format "MP3 128kbps Stereo"
:mount "asteroid.mp3"))))
(ps:getprop config quality)))
;; Get current channel from selector or localStorage
(defun get-current-channel ()
@ -672,101 +667,52 @@
(defvar *reconnect-timeout* nil)
(defvar *is-reconnecting* false)
;; Reconnect stream - recreates audio element to fix wedged state
;; Reconnect stream - reuses existing audio element to preserve user gesture context
(defun reconnect-stream ()
(ps:chain console (log "Reconnecting stream..."))
(show-status "🔄 Reconnecting..." false)
(let* ((container (ps:chain document (query-selector ".persistent-player")))
(old-audio (ps:chain document (get-element-by-id "persistent-audio")))
(let* ((audio (ps:chain document (get-element-by-id "persistent-audio")))
(source (ps:chain document (get-element-by-id "audio-source")))
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
(stream-channel (get-current-channel))
(stream-quality (get-current-quality))
(config (get-stream-config stream-base-url stream-channel stream-quality)))
(unless (and container old-audio)
(unless audio
(show-status "❌ Could not reconnect - reload page" true)
(setf *is-reconnecting* false)
(return-from reconnect-stream nil))
;; Save current volume and muted state
(let ((saved-volume (ps:@ old-audio volume))
(saved-muted (ps:@ old-audio muted)))
(ps:chain console (log "Saving volume:" saved-volume "muted:" saved-muted))
(ps:chain console (log "Saving volume:" (ps:@ audio volume) "muted:" (ps:@ audio muted)))
;; Reset spectrum analyzer if it exists
(when (ps:@ window reset-spectrum-analyzer)
(ps:chain window (reset-spectrum-analyzer)))
;; Stop and remove old audio
(ps:chain old-audio (pause))
(setf (ps:@ old-audio src) "")
(ps:chain old-audio (load))
;; Create new audio element
(let ((new-audio (ps:chain document (create-element "audio"))))
(setf (ps:@ new-audio id) "persistent-audio")
(setf (ps:@ new-audio controls) true)
(setf (ps:@ new-audio preload) "metadata")
(setf (ps:@ new-audio cross-origin) "anonymous")
;; Restore volume and muted state
(setf (ps:@ new-audio volume) saved-volume)
(setf (ps:@ new-audio muted) saved-muted)
;; Create source
(let ((source (ps:chain document (create-element "source"))))
(setf (ps:@ source id) "audio-source")
(setf (ps:@ source src) (ps:@ config url))
(setf (ps:@ source type) (ps:@ config type))
(ps:chain new-audio (append-child source)))
;; Replace old audio with new
(ps:chain old-audio (replace-with new-audio))
;; Re-attach event listeners
(attach-audio-listeners new-audio)
;; Try to play - reset flag so error handler can catch failures
(setf *is-reconnecting* false)
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(ps:chain console (log "Reconnected successfully"))
(show-status "✓ Reconnected!" false)
;; Reinitialize spectrum analyzer
(when (ps:@ window init-spectrum-analyzer)
(set-timeout (lambda ()
(ps:chain window (init-spectrum-analyzer)))
500))
;; Also try in content frame
(set-timeout
(lambda ()
(ps:try
(let ((content-frame (ps:@ (ps:@ window parent) frames "content-frame")))
(when (and content-frame (ps:@ content-frame init-spectrum-analyzer))
(when (ps:@ content-frame reset-spectrum-analyzer)
(ps:chain content-frame (reset-spectrum-analyzer)))
(ps:chain content-frame (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized in content frame"))))
(:catch (e)
(ps:chain console (log "Could not reinit spectrum in content frame:" e)))))
600)))
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err))
;; Retry with exponential backoff
(incf *stream-error-count*)
(if (< *stream-error-count* 5)
(let ((delay (* 2000 *stream-error-count*)))
(show-status (+ "⚠️ Reconnect failed, retrying in " (/ delay 1000) "s...") true)
(setf *is-reconnecting* false)
(setf *reconnect-timeout*
(set-timeout (lambda () (reconnect-stream)) delay)))
;; Reload source on existing element (preserves user gesture context)
(ps:chain audio (pause))
(if source
;; Update existing source element
(progn
(setf (ps:@ source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time))))
(setf (ps:@ source type) (ps:@ config type)))
;; Create source if missing
(let ((new-source (ps:chain document (create-element "source"))))
(setf (ps:@ new-source id) "audio-source")
(setf (ps:@ new-source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time))))
(setf (ps:@ new-source type) (ps:@ config type))
(ps:chain audio (append-child new-source))))
;; Reload and play
(ps:chain audio (load))
(setf *is-reconnecting* false)
(show-status "❌ Could not reconnect. Click play to try again." true)))))))
300)))))
(set-timeout
(lambda ()
(ps:chain audio (play)
(catch (lambda (error)
(ps:chain console (log "Reconnect play failed:" error))))))
200)))
;; Simple reconnect for popout player (just reload and play)
(defun simple-reconnect (audio-element)

View File

@ -68,18 +68,26 @@
(values skip-ok reload-ok)))
(defun load-scheduled-playlist (playlist-name)
"Load a playlist by name, copying it to stream-queue.m3u and triggering playback."
"Load a playlist by name and trigger playback.
Uses Harmony pipeline when available, falls back to Liquidsoap."
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
(if (probe-file playlist-path)
(progn
(copy-playlist-to-stream-queue playlist-path)
(load-queue-from-m3u-file)
(if *harmony-pipeline*
;; Use cl-streamer directly
(let ((count (harmony-load-playlist playlist-path)))
(if count
(log:info "Scheduler loaded ~a (~a tracks via Harmony)" playlist-name count)
(log:error "Scheduler failed to load ~a via Harmony" playlist-name)))
;; Fallback to Liquidsoap
(multiple-value-bind (skip-ok reload-ok)
(liquidsoap-reload-and-skip)
(if (and reload-ok skip-ok)
(log:info "Scheduler loaded ~a" playlist-name)
(log:error "Scheduler failed to switch to ~a (reload:~a skip:~a)"
playlist-name reload-ok skip-ok)))
playlist-name reload-ok skip-ok))))
t)
(progn
(log:error "Scheduler playlist not found: ~a" playlist-name)

202
stream-harmony.lisp Normal file
View File

@ -0,0 +1,202 @@
;;;; stream-harmony.lisp - CL-Streamer / Harmony integration for Asteroid Radio
;;;; Replaces the Icecast + Liquidsoap stack with in-process audio streaming.
;;;; Provides the same data interface to frontend-partials and admin APIs.
(in-package :asteroid)
;;; ---- Configuration ----
(defvar *harmony-pipeline* nil
"The active cl-streamer/harmony audio pipeline.")
(defvar *harmony-stream-port* 8000
"Port for the cl-streamer HTTP stream server.")
(defvar *harmony-mp3-encoder* nil
"MP3 encoder instance.")
(defvar *harmony-aac-encoder* nil
"AAC encoder instance.")
;;; ---- M3U Playlist Loading ----
(defun m3u-to-file-list (m3u-path)
"Parse an M3U playlist file and return a list of host file paths.
Converts Docker paths (/app/music/...) back to host paths.
Skips comment lines and blank lines."
(when (probe-file m3u-path)
(with-open-file (stream m3u-path :direction :input)
(loop for line = (read-line stream nil)
while line
for trimmed = (string-trim '(#\Space #\Tab #\Return #\Newline) line)
unless (or (string= trimmed "")
(and (> (length trimmed) 0) (char= (char trimmed 0) #\#)))
collect (convert-from-docker-path trimmed)))))
;;; ---- Track Change Callback ----
(defun on-harmony-track-change (pipeline track-info)
"Called by cl-streamer when a track changes.
Updates recently-played lists and finds the track in the database."
(declare (ignore pipeline))
(let* ((display-title (getf track-info :display-title))
(artist (getf track-info :artist))
(title (getf track-info :title))
(file-path (getf track-info :file))
(track-id (or (find-track-by-title display-title)
(find-track-by-file-path file-path))))
(when (and display-title
(not (string= display-title "Unknown")))
;; Update recently played (curated stream)
(add-recently-played (list :title display-title
:artist artist
:song title
:timestamp (get-universal-time)
:track-id track-id)
:curated)
(setf *last-known-track-curated* display-title))
(log:info "Track change: ~A (track-id: ~A)" display-title track-id)))
(defun find-track-by-file-path (file-path)
"Find a track in the database by file path. Returns track ID or nil."
(when file-path
(handler-case
(with-db
(postmodern:query
(:limit
(:select '_id :from 'tracks
:where (:= 'file-path file-path))
1)
:single))
(error () nil))))
;;; ---- Now-Playing Data Source ----
;;; These functions provide the same data that icecast-now-playing returned,
;;; but sourced directly from cl-streamer's pipeline state.
(defun harmony-now-playing (&optional (mount "asteroid.mp3"))
"Get now-playing information from cl-streamer pipeline.
Returns an alist compatible with the icecast-now-playing format,
or NIL if the pipeline is not running."
(when (and *harmony-pipeline*
(cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(let* ((track-info (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(display-title (or (getf track-info :display-title) "Unknown"))
(listeners (cl-streamer:get-listener-count))
(track-id (or (find-track-by-title display-title)
(find-track-by-file-path (getf track-info :file)))))
`((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount))
(:title . ,display-title)
(:listeners . ,(or listeners 0))
(:track-id . ,track-id)
(:favorite-count . ,(or (get-track-favorite-count display-title) 0))))))
;;; ---- Pipeline Lifecycle ----
(defun start-harmony-streaming (&key (port *harmony-stream-port*)
(mp3-bitrate 128000)
(aac-bitrate 128000))
"Start the cl-streamer pipeline with MP3 and AAC outputs.
Should be called once during application startup."
(when *harmony-pipeline*
(log:warn "Harmony streaming already running")
(return-from start-harmony-streaming *harmony-pipeline*))
;; Start the stream server
(cl-streamer:start :port port)
;; Add mount points
(cl-streamer:add-mount cl-streamer:*server* "/asteroid.mp3"
:content-type "audio/mpeg"
:bitrate 128
:name "Asteroid Radio MP3")
(cl-streamer:add-mount cl-streamer:*server* "/asteroid.aac"
:content-type "audio/aac"
:bitrate 128
:name "Asteroid Radio AAC")
;; Create encoders
(setf *harmony-mp3-encoder*
(cl-streamer:make-mp3-encoder :bitrate (floor mp3-bitrate 1000)
:sample-rate 44100
:channels 2))
(setf *harmony-aac-encoder*
(cl-streamer:make-aac-encoder :bitrate aac-bitrate
:sample-rate 44100
:channels 2))
;; Create pipeline with track-change callback
(setf *harmony-pipeline*
(cl-streamer/harmony:make-audio-pipeline
:encoder *harmony-mp3-encoder*
:stream-server cl-streamer:*server*
:mount-path "/asteroid.mp3"))
;; Add AAC output
(cl-streamer/harmony:add-pipeline-output *harmony-pipeline*
*harmony-aac-encoder*
"/asteroid.aac")
;; Set the track-change callback
(setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*)
#'on-harmony-track-change)
;; Start the audio pipeline
(cl-streamer/harmony:start-pipeline *harmony-pipeline*)
(log:info "Harmony streaming started on port ~A (MP3 + AAC)" port)
*harmony-pipeline*)
(defun stop-harmony-streaming ()
"Stop the cl-streamer pipeline and stream server."
(when *harmony-pipeline*
(cl-streamer/harmony:stop-pipeline *harmony-pipeline*)
(setf *harmony-pipeline* nil))
(when *harmony-mp3-encoder*
(cl-streamer:close-encoder *harmony-mp3-encoder*)
(setf *harmony-mp3-encoder* nil))
(when *harmony-aac-encoder*
(cl-streamer:close-aac-encoder *harmony-aac-encoder*)
(setf *harmony-aac-encoder* nil))
(cl-streamer:stop)
(log:info "Harmony streaming stopped"))
;;; ---- Playlist Control (replaces Liquidsoap commands) ----
(defun harmony-load-playlist (m3u-path)
"Load and start playing an M3U playlist through the Harmony pipeline.
Converts Docker paths to host paths and feeds them to play-list."
(when *harmony-pipeline*
(let ((file-list (m3u-to-file-list m3u-path)))
(when file-list
;; Clear any existing queue and load new files
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
(mapcar (lambda (path)
(list :file path))
file-list))
;; Skip current track to trigger crossfade into new playlist
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*)
(log:info "Loaded playlist ~A (~A tracks)" m3u-path (length file-list))
(length file-list)))))
(defun harmony-skip-track ()
"Skip the current track (crossfades to next)."
(when *harmony-pipeline*
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*)
t))
(defun harmony-get-status ()
"Get current pipeline status (replaces liquidsoap status)."
(if *harmony-pipeline*
(let ((track (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(listeners (cl-streamer:get-listener-count)))
(list :running t
:current-track (getf track :display-title)
:artist (getf track :artist)
:title (getf track :title)
:album (getf track :album)
:listeners listeners
:queue-length (length (cl-streamer/harmony:pipeline-get-queue
*harmony-pipeline*))))
(list :running nil)))