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:
parent
dad1418bf8
commit
77458467c4
132
asteroid.lisp
132
asteroid.lisp
|
|
@ -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,25 +1010,31 @@
|
||||||
|
|
||||||
;; 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.
|
||||||
(handler-case
|
Uses Harmony pipeline status when available, falls back to Icecast HTTP check."
|
||||||
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
|
(if *harmony-pipeline*
|
||||||
:want-stream nil
|
"🟢 Running (cl-streamer)"
|
||||||
:connection-timeout 2)))
|
(handler-case
|
||||||
(if response "🟢 Running" "🔴 Not Running"))
|
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
|
||||||
(error () "🔴 Not Running")))
|
:want-stream nil
|
||||||
|
:connection-timeout 2)))
|
||||||
|
(if response "🟢 Running" "🔴 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.
|
||||||
(handler-case
|
Returns N/A when using cl-streamer."
|
||||||
(let* ((output (with-output-to-string (stream)
|
(if *harmony-pipeline*
|
||||||
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
|
"⚪ N/A (using cl-streamer)"
|
||||||
:output stream
|
(handler-case
|
||||||
:error-output nil
|
(let* ((output (with-output-to-string (stream)
|
||||||
:ignore-error-status t)))
|
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
|
||||||
(running-p (search "Up" output)))
|
:output stream
|
||||||
(if running-p "🟢 Running" "🔴 Not Running"))
|
:error-output nil
|
||||||
(error () "🔴 Not Running")))
|
:ignore-error-status t)))
|
||||||
|
(running-p (search "Up" output)))
|
||||||
|
(if running-p "🟢 Running" "🔴 Not Running"))
|
||||||
|
(error () "🔴 Not Running"))))
|
||||||
|
|
||||||
;; Admin page (requires authentication)
|
;; Admin page (requires authentication)
|
||||||
(define-page admin #@"/admin" ()
|
(define-page admin #@"/admin" ()
|
||||||
|
|
@ -1376,41 +1382,48 @@
|
||||||
("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
|
||||||
(let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
|
(if *harmony-pipeline*
|
||||||
(response (drakma:http-request icecast-url
|
;; Return status from cl-streamer directly
|
||||||
:want-stream nil
|
(let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
|
||||||
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
(title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
|
||||||
(if response
|
(listeners (if now-playing (cdr (assoc :listeners now-playing)) 0)))
|
||||||
(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)
|
|
||||||
(if match-start
|
|
||||||
(let* ((source-section (subseq xml-string match-start
|
|
||||||
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
|
|
||||||
(length xml-string))))
|
|
||||||
(titlep (cl-ppcre:all-matches "<title>" source-section))
|
|
||||||
(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
|
(api-output
|
||||||
`(("error" . "Could not connect to Icecast server"))
|
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
:status 503)))))
|
("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
|
||||||
|
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||||
|
(if response
|
||||||
|
(let ((xml-string (if (stringp response)
|
||||||
|
response
|
||||||
|
(babel:octets-to-string response :encoding :utf-8))))
|
||||||
|
(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)
|
||||||
|
(length xml-string))))
|
||||||
|
(titlep (cl-ppcre:all-matches "<title>" source-section))
|
||||||
|
(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")))
|
||||||
|
(api-output
|
||||||
|
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
|
("title" . ,title)
|
||||||
|
("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
|
||||||
|
(api-output
|
||||||
|
`(("icestats" . (("source" . nil))))))))
|
||||||
|
(api-output
|
||||||
|
`(("error" . "Could not connect to Icecast server"))
|
||||||
|
: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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
|
|
@ -476,16 +476,25 @@
|
||||||
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.
|
||||||
(let ((stats (fetch-icecast-stats)))
|
Uses cl-streamer listener counts when Harmony is running, falls back to Icecast."
|
||||||
(when stats
|
(if *harmony-pipeline*
|
||||||
(let ((sources (parse-icecast-sources stats)))
|
;; Get listener counts directly from cl-streamer
|
||||||
(dolist (source sources)
|
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
||||||
(let ((mount (getf source :mount))
|
(let ((listeners (cl-streamer:get-listener-count mount)))
|
||||||
(listeners (getf source :listeners)))
|
(when (and listeners (> listeners 0))
|
||||||
(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)))))))
|
;; Fallback: poll Icecast
|
||||||
|
(let ((stats (fetch-icecast-stats)))
|
||||||
|
(when stats
|
||||||
|
(let ((sources (parse-icecast-sources stats)))
|
||||||
|
(dolist (source sources)
|
||||||
|
(let ((mount (getf source :mount))
|
||||||
|
(listeners (getf source :listeners)))
|
||||||
|
(when mount
|
||||||
|
(store-listener-snapshot 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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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*)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue