diff --git a/asteroid.lisp b/asteroid.lisp index 0e79ea6..c281ee2 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -853,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" @@ -1010,25 +1010,31 @@ ;; Status check functions (defun check-icecast-status () - "Check if Icecast server is running and accessible" - (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"))) + "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")))) (defun check-liquidsoap-status () - "Check if Liquidsoap is running via Docker" - (handler-case - (let* ((output (with-output-to-string (stream) - (uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}") - :output stream - :error-output nil - :ignore-error-status t))) - (running-p (search "Up" output))) - (if running-p "🟢 Running" "🔴 Not Running")) - (error () "🔴 Not Running"))) + "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}}") + :output stream + :error-output nil + :ignore-error-status t))) + (running-p (search "Up" output))) + (if running-p "🟢 Running" "🔴 Not Running")) + (error () "🔴 Not Running")))) ;; Admin page (requires authentication) (define-page admin #@"/admin" () @@ -1376,41 +1382,48 @@ ("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 - (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)))) - ;; Simple XML parsing to extract source information - ;; Look for sections and extract title, listeners, etc. - (multiple-value-bind (match-start match-end) - (cl-ppcre:scan "" xml-string) - (if match-start - (let* ((source-section (subseq xml-string match-start - (or (cl-ppcre:scan "" xml-string :start match-start) - (length xml-string)))) - (titlep (cl-ppcre:all-matches "" source-section)) - (listenersp (cl-ppcre:all-matches "<listeners>" source-section)) - (title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?).*" source-section "\\1") "Unknown")) - (listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?).*" 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)))))))) + (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 - `(("error" . "Could not connect to Icecast server")) - :status 503))))) + `(("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 + :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 "" xml-string) + (declare (ignore match-end)) + (if match-start + (let* ((source-section (subseq xml-string match-start + (or (cl-ppcre:scan "" xml-string :start match-start) + (length xml-string)))) + (titlep (cl-ppcre:all-matches "" source-section)) + (listenersp (cl-ppcre:all-matches "<listeners>" source-section)) + (title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?).*" source-section "\\1") "Unknown")) + (listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?).*" 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 @@ -1554,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)) diff --git a/cl-streamer/icy-protocol.lisp b/cl-streamer/icy-protocol.lisp index b5e9f2f..ff45dab 100644 --- a/cl-streamer/icy-protocol.lisp +++ b/cl-streamer/icy-protocol.lisp @@ -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) diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 7641506..92fd3af 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -93,19 +93,18 @@ (:track-id . ,(find-track-by-title title)) (: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. Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count." (or (harmony-now-playing mount) - (icecast-now-playing *stream-base-url* - (if (string= mount "stream.mp3") "asteroid.mp3" 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 now-playing status. Optional MOUNT parameter specifies which stream to get metadata from. Uses Harmony pipeline when available, falls back to Icecast." (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))) (if 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). Optional MOUNT parameter specifies which stream to get metadata from." (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))) (if now-playing-stats (progn @@ -143,7 +142,7 @@ ;; Register web listener for geo stats (keeps listener active during playback) (register-web-listener) (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))) (if now-playing-stats (let* ((title (cdr (assoc :title now-playing-stats))) diff --git a/listener-stats.lisp b/listener-stats.lisp index 2530a27..64747cc 100644 --- a/listener-stats.lisp +++ b/listener-stats.lisp @@ -476,16 +476,25 @@ location-counts))) (defun poll-and-store-stats () - "Single poll iteration: fetch stats and store" - (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))))))) + "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))) + (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)) diff --git a/stream-harmony.lisp b/stream-harmony.lisp index 924ca37..c9dbad7 100644 --- a/stream-harmony.lisp +++ b/stream-harmony.lisp @@ -74,7 +74,7 @@ ;;; 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 "stream.mp3")) +(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." @@ -107,11 +107,11 @@ (cl-streamer:start :port port) ;; 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" :bitrate 128 :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" :bitrate 128 :name "Asteroid Radio AAC") @@ -131,12 +131,12 @@ (cl-streamer/harmony:make-audio-pipeline :encoder *harmony-mp3-encoder* :stream-server cl-streamer:*server* - :mount-path "/stream.mp3")) + :mount-path "/asteroid.mp3")) ;; Add AAC output (cl-streamer/harmony:add-pipeline-output *harmony-pipeline* *harmony-aac-encoder* - "/stream.aac") + "/asteroid.aac") ;; Set the track-change callback (setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*)