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
This commit is contained in:
Glenn Thompson 2026-03-03 22:29:21 +03:00
parent dad1418bf8
commit 77458467c4
5 changed files with 113 additions and 71 deletions

View File

@ -853,7 +853,7 @@
"Main front page" "Main front page"
;; Register this visitor for geo stats (captures real IP from X-Forwarded-For) ;; Register this visitor for geo stats (captures real IP from X-Forwarded-For)
(register-web-listener) (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 (clip:process-to-string
(load-template "front-page") (load-template "front-page")
:title "ASTEROID RADIO" :title "ASTEROID RADIO"
@ -1010,16 +1010,22 @@
;; Status check functions ;; Status check functions
(defun check-icecast-status () (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 (handler-case
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*) (let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
:want-stream nil :want-stream nil
:connection-timeout 2))) :connection-timeout 2)))
(if response "🟢 Running" "🔴 Not Running")) (if response "🟢 Running" "🔴 Not Running"))
(error () "🔴 Not Running"))) (error () "🔴 Not Running"))))
(defun check-liquidsoap-status () (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 (handler-case
(let* ((output (with-output-to-string (stream) (let* ((output (with-output-to-string (stream)
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}") (uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
@ -1028,7 +1034,7 @@
:ignore-error-status t))) :ignore-error-status t)))
(running-p (search "Up" output))) (running-p (search "Up" output)))
(if running-p "🟢 Running" "🔴 Not Running")) (if running-p "🟢 Running" "🔴 Not Running"))
(error () "🔴 Not Running"))) (error () "🔴 Not Running"))))
;; Admin page (requires authentication) ;; Admin page (requires authentication)
(define-page admin #@"/admin" () (define-page admin #@"/admin" ()
@ -1376,10 +1382,20 @@
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) ("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("stream-status" . "live")))) ("stream-status" . "live"))))
;; Live stream status from Icecast ;; Live stream status
(define-api-with-limit asteroid/icecast-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 (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 (if now-playing (cdr (assoc :listeners now-playing)) 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*)) (let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
(response (drakma:http-request icecast-url (response (drakma:http-request icecast-url
:want-stream nil :want-stream nil
@ -1388,10 +1404,9 @@
(let ((xml-string (if (stringp response) (let ((xml-string (if (stringp response)
response response
(babel:octets-to-string response :encoding :utf-8)))) (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) (multiple-value-bind (match-start match-end)
(cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string) (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)
(declare (ignore match-end))
(if match-start (if match-start
(let* ((source-section (subseq xml-string match-start (let* ((source-section (subseq xml-string match-start
(or (cl-ppcre:scan "</source>" xml-string :start match-start) (or (cl-ppcre:scan "</source>" xml-string :start match-start)
@ -1400,17 +1415,15 @@
(listenersp (cl-ppcre:all-matches "<listeners>" source-section)) (listenersp (cl-ppcre:all-matches "<listeners>" source-section))
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown")) (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"))) (listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
;; Return JSON in format expected by frontend
(api-output (api-output
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("title" . ,title) ("title" . ,title)
("listeners" . ,(parse-integer listeners :junk-allowed t))))))))) ("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
;; No source found, return empty
(api-output (api-output
`(("icestats" . (("source" . nil)))))))) `(("icestats" . (("source" . nil))))))))
(api-output (api-output
`(("error" . "Could not connect to Icecast server")) `(("error" . "Could not connect to Icecast server"))
:status 503))))) :status 503))))))
;;; Listener Statistics API Endpoints ;;; Listener Statistics API Endpoints
@ -1554,4 +1567,23 @@
;; TODO: Add auto-scan on startup once database timing issues are resolved ;; TODO: Add auto-scan on startup once database timing issues are resolved
;; For now, use the "Scan Library" button in the admin interface ;; 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)) (run-server))

View File

@ -50,6 +50,8 @@
(format stream "icy-br: ~A~C~C" bitrate #\Return #\Linefeed) (format stream "icy-br: ~A~C~C" bitrate #\Return #\Linefeed)
(when metaint (when metaint
(format stream "icy-metaint: ~A~C~C" metaint #\Return #\Linefeed)) (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 "Cache-Control: no-cache, no-store~C~C" #\Return #\Linefeed)
(format stream "Connection: close~C~C" #\Return #\Linefeed) (format stream "Connection: close~C~C" #\Return #\Linefeed)
(format stream "~C~C" #\Return #\Linefeed) (format stream "~C~C" #\Return #\Linefeed)

View File

@ -93,19 +93,18 @@
(:track-id . ,(find-track-by-title title)) (:track-id . ,(find-track-by-title title))
(:favorite-count . ,(or (get-track-favorite-count title) 1)))))))) (:favorite-count . ,(or (get-track-favorite-count title) 1))))))))
(defun get-now-playing-stats (&optional (mount "stream.mp3")) (defun get-now-playing-stats (&optional (mount "asteroid.mp3"))
"Get now-playing stats from Harmony pipeline, falling back to Icecast. "Get now-playing stats from Harmony pipeline, falling back to Icecast.
Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count." Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count."
(or (harmony-now-playing mount) (or (harmony-now-playing mount)
(icecast-now-playing *stream-base-url* (icecast-now-playing *stream-base-url* mount)))
(if (string= mount "stream.mp3") "asteroid.mp3" mount))))
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1) (define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1)
"Get Partial HTML with live now-playing status. "Get Partial HTML with live now-playing status.
Optional MOUNT parameter specifies which stream to get metadata from. Optional MOUNT parameter specifies which stream to get metadata from.
Uses Harmony pipeline when available, falls back to Icecast." Uses Harmony pipeline when available, falls back to Icecast."
(with-error-handling (with-error-handling
(let* ((mount-name (or mount "stream.mp3")) (let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (get-now-playing-stats mount-name))) (now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats (if now-playing-stats
(let* ((title (cdr (assoc :title now-playing-stats))) (let* ((title (cdr (assoc :title now-playing-stats)))
@ -127,7 +126,7 @@
"Get inline text with now playing info (for admin dashboard and widgets). "Get inline text with now playing info (for admin dashboard and widgets).
Optional MOUNT parameter specifies which stream to get metadata from." Optional MOUNT parameter specifies which stream to get metadata from."
(with-error-handling (with-error-handling
(let* ((mount-name (or mount "stream.mp3")) (let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (get-now-playing-stats mount-name))) (now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats (if now-playing-stats
(progn (progn
@ -143,7 +142,7 @@
;; Register web listener for geo stats (keeps listener active during playback) ;; Register web listener for geo stats (keeps listener active during playback)
(register-web-listener) (register-web-listener)
(with-error-handling (with-error-handling
(let* ((mount-name (or mount "stream.mp3")) (let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (get-now-playing-stats mount-name))) (now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats (if now-playing-stats
(let* ((title (cdr (assoc :title now-playing-stats))) (let* ((title (cdr (assoc :title now-playing-stats)))

View File

@ -476,7 +476,16 @@
location-counts))) location-counts)))
(defun poll-and-store-stats () (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))) (let ((stats (fetch-icecast-stats)))
(when stats (when stats
(let ((sources (parse-icecast-sources stats))) (let ((sources (parse-icecast-sources stats)))
@ -485,7 +494,7 @@
(listeners (getf source :listeners))) (listeners (getf source :listeners)))
(when mount (when mount
(store-listener-snapshot mount listeners) (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 (uses real IPs from X-Forwarded-For)
(collect-geo-stats-from-web-listeners)) (collect-geo-stats-from-web-listeners))

View File

@ -74,7 +74,7 @@
;;; These functions provide the same data that icecast-now-playing returned, ;;; These functions provide the same data that icecast-now-playing returned,
;;; but sourced directly from cl-streamer's pipeline state. ;;; but sourced directly from cl-streamer's pipeline state.
(defun harmony-now-playing (&optional (mount "stream.mp3")) (defun harmony-now-playing (&optional (mount "asteroid.mp3"))
"Get now-playing information from cl-streamer pipeline. "Get now-playing information from cl-streamer pipeline.
Returns an alist compatible with the icecast-now-playing format, Returns an alist compatible with the icecast-now-playing format,
or NIL if the pipeline is not running." or NIL if the pipeline is not running."
@ -107,11 +107,11 @@
(cl-streamer:start :port port) (cl-streamer:start :port port)
;; Add mount points ;; Add mount points
(cl-streamer:add-mount cl-streamer:*server* "/stream.mp3" (cl-streamer:add-mount cl-streamer:*server* "/asteroid.mp3"
:content-type "audio/mpeg" :content-type "audio/mpeg"
:bitrate 128 :bitrate 128
:name "Asteroid Radio MP3") :name "Asteroid Radio MP3")
(cl-streamer:add-mount cl-streamer:*server* "/stream.aac" (cl-streamer:add-mount cl-streamer:*server* "/asteroid.aac"
:content-type "audio/aac" :content-type "audio/aac"
:bitrate 128 :bitrate 128
:name "Asteroid Radio AAC") :name "Asteroid Radio AAC")
@ -131,12 +131,12 @@
(cl-streamer/harmony:make-audio-pipeline (cl-streamer/harmony:make-audio-pipeline
:encoder *harmony-mp3-encoder* :encoder *harmony-mp3-encoder*
:stream-server cl-streamer:*server* :stream-server cl-streamer:*server*
:mount-path "/stream.mp3")) :mount-path "/asteroid.mp3"))
;; Add AAC output ;; Add AAC output
(cl-streamer/harmony:add-pipeline-output *harmony-pipeline* (cl-streamer/harmony:add-pipeline-output *harmony-pipeline*
*harmony-aac-encoder* *harmony-aac-encoder*
"/stream.aac") "/asteroid.aac")
;; Set the track-change callback ;; Set the track-change callback
(setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*) (setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*)