Compare commits
No commits in common. "6e23efe1e46281440b9afe5dd9e2dcee64743526" and "edf9326007a8668ddd45f245ecc47c3a5a190e28" have entirely different histories.
6e23efe1e4
...
edf9326007
|
|
@ -29,11 +29,6 @@
|
||||||
:bordeaux-threads
|
:bordeaux-threads
|
||||||
:drakma
|
:drakma
|
||||||
:cl-cron
|
:cl-cron
|
||||||
;; CL-Streamer (replaces Icecast + Liquidsoap)
|
|
||||||
:cl-streamer
|
|
||||||
:cl-streamer/encoder
|
|
||||||
:cl-streamer/aac-encoder
|
|
||||||
:cl-streamer/harmony
|
|
||||||
;; radiance interfaces
|
;; radiance interfaces
|
||||||
:i-log4cl
|
:i-log4cl
|
||||||
:i-postmodern
|
:i-postmodern
|
||||||
|
|
@ -69,7 +64,6 @@
|
||||||
(:file "user-management")
|
(:file "user-management")
|
||||||
(:file "playlist-management")
|
(:file "playlist-management")
|
||||||
(:file "stream-control")
|
(:file "stream-control")
|
||||||
(:file "stream-harmony")
|
|
||||||
(:file "playlist-scheduler")
|
(:file "playlist-scheduler")
|
||||||
(:file "listener-stats")
|
(:file "listener-stats")
|
||||||
(:file "user-profile")
|
(:file "user-profile")
|
||||||
|
|
|
||||||
226
asteroid.lisp
226
asteroid.lisp
|
|
@ -442,13 +442,11 @@
|
||||||
;; Load into in-memory queue
|
;; Load into in-memory queue
|
||||||
(let ((count (load-queue-from-m3u-file))
|
(let ((count (load-queue-from-m3u-file))
|
||||||
(channel-name (get-curated-channel-name)))
|
(channel-name (get-curated-channel-name)))
|
||||||
;; Skip/switch to new playlist
|
;; Skip current track to trigger crossfade to new playlist
|
||||||
(if *harmony-pipeline*
|
(handler-case
|
||||||
(harmony-load-playlist playlist-path)
|
(liquidsoap-command "stream-queue_m3u.skip")
|
||||||
(handler-case
|
(error (e)
|
||||||
(liquidsoap-command "stream-queue_m3u.skip")
|
(format *error-output* "Warning: Could not skip track: ~a~%" e)))
|
||||||
(error (e)
|
|
||||||
(format *error-output* "Warning: Could not skip track: ~a~%" e))))
|
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . ,(format nil "Loaded playlist: ~a" name))
|
("message" . ,(format nil "Loaded playlist: ~a" name))
|
||||||
("count" . ,count)
|
("count" . ,count)
|
||||||
|
|
@ -570,74 +568,48 @@
|
||||||
(error () seconds-str)))
|
(error () seconds-str)))
|
||||||
|
|
||||||
(define-api asteroid/liquidsoap/status () ()
|
(define-api asteroid/liquidsoap/status () ()
|
||||||
"Get stream status - uses Harmony pipeline when available, falls back to Liquidsoap"
|
"Get Liquidsoap status including uptime and current track"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
(let ((uptime (liquidsoap-command "uptime"))
|
||||||
(let ((status (harmony-get-status)))
|
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
|
||||||
(api-output `(("status" . "success")
|
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
|
||||||
("backend" . "harmony")
|
(api-output `(("status" . "success")
|
||||||
("uptime" . "n/a")
|
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
|
||||||
("metadata" . ,(getf status :current-track))
|
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
|
||||||
("remaining" . "n/a")
|
("remaining" . ,(format-remaining-time
|
||||||
("listeners" . ,(getf status :listeners))
|
(string-trim '(#\Space #\Newline #\Return) remaining-raw))))))))
|
||||||
("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)))))))))
|
|
||||||
|
|
||||||
(define-api asteroid/liquidsoap/skip () ()
|
(define-api asteroid/liquidsoap/skip () ()
|
||||||
"Skip the current track"
|
"Skip the current track in Liquidsoap"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
|
||||||
(progn
|
(api-output `(("status" . "success")
|
||||||
(harmony-skip-track)
|
("message" . "Track skipped")
|
||||||
(api-output `(("status" . "success")
|
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
|
||||||
("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))))))))
|
|
||||||
|
|
||||||
(define-api asteroid/liquidsoap/reload () ()
|
(define-api asteroid/liquidsoap/reload () ()
|
||||||
"Force playlist reload"
|
"Force Liquidsoap to reload the playlist"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
|
||||||
(let* ((playlist-path (get-stream-queue-path))
|
(api-output `(("status" . "success")
|
||||||
(count (harmony-load-playlist playlist-path)))
|
("message" . "Playlist reloaded")
|
||||||
(api-output `(("status" . "success")
|
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
|
||||||
("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))))))))
|
|
||||||
|
|
||||||
(define-api asteroid/liquidsoap/restart () ()
|
(define-api asteroid/liquidsoap/restart () ()
|
||||||
"Restart the streaming backend"
|
"Restart the Liquidsoap Docker container"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
(let ((result (uiop:run-program
|
||||||
(progn
|
"docker restart asteroid-liquidsoap"
|
||||||
(stop-harmony-streaming)
|
:output :string
|
||||||
(start-harmony-streaming)
|
:error-output :string
|
||||||
(api-output `(("status" . "success")
|
:ignore-error-status t)))
|
||||||
("message" . "Harmony pipeline restarted"))))
|
(api-output `(("status" . "success")
|
||||||
(let ((result (uiop:run-program
|
("message" . "Liquidsoap container restarting")
|
||||||
"docker restart asteroid-liquidsoap"
|
("result" . ,result))))))
|
||||||
:output :string
|
|
||||||
:error-output :string
|
|
||||||
:ignore-error-status t)))
|
|
||||||
(api-output `(("status" . "success")
|
|
||||||
("message" . "Liquidsoap container restarting")
|
|
||||||
("result" . ,result)))))))
|
|
||||||
|
|
||||||
(define-api asteroid/icecast/restart () ()
|
(define-api asteroid/icecast/restart () ()
|
||||||
"Restart the Icecast Docker container"
|
"Restart the Icecast Docker container"
|
||||||
|
|
@ -853,7 +825,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 (get-now-playing-stats)))
|
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
(load-template "front-page")
|
(load-template "front-page")
|
||||||
:title "ASTEROID RADIO"
|
:title "ASTEROID RADIO"
|
||||||
|
|
@ -1010,31 +982,25 @@
|
||||||
|
|
||||||
;; Status check functions
|
;; Status check functions
|
||||||
(defun check-icecast-status ()
|
(defun check-icecast-status ()
|
||||||
"Check if streaming backend is running.
|
"Check if Icecast server is running and accessible"
|
||||||
Uses Harmony pipeline status when available, falls back to Icecast HTTP check."
|
(handler-case
|
||||||
(if *harmony-pipeline*
|
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
|
||||||
"🟢 Running (cl-streamer)"
|
:want-stream nil
|
||||||
(handler-case
|
:connection-timeout 2)))
|
||||||
(let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
|
(if response "🟢 Running" "🔴 Not Running"))
|
||||||
:want-stream nil
|
(error () "🔴 Not Running")))
|
||||||
: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"
|
||||||
Returns N/A when using cl-streamer."
|
(handler-case
|
||||||
(if *harmony-pipeline*
|
(let* ((output (with-output-to-string (stream)
|
||||||
"⚪ N/A (using cl-streamer)"
|
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
|
||||||
(handler-case
|
:output stream
|
||||||
(let* ((output (with-output-to-string (stream)
|
:error-output nil
|
||||||
(uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
|
:ignore-error-status t)))
|
||||||
:output stream
|
(running-p (search "Up" output)))
|
||||||
:error-output nil
|
(if running-p "🟢 Running" "🔴 Not Running"))
|
||||||
:ignore-error-status t)))
|
(error () "🔴 Not Running")))
|
||||||
(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" ()
|
||||||
|
|
@ -1382,48 +1348,41 @@
|
||||||
("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
|
;; Live stream status from Icecast
|
||||||
(define-api-with-limit asteroid/icecast-status () ()
|
(define-api-with-limit asteroid/icecast-status () ()
|
||||||
"Get live stream status. Uses Harmony pipeline when available, falls back to Icecast."
|
"Get live status from Icecast server"
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
(let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
|
||||||
;; Return status from cl-streamer directly
|
(response (drakma:http-request icecast-url
|
||||||
(let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
|
:want-stream nil
|
||||||
(title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
|
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||||
(listeners (or (cl-streamer:get-listener-count) 0)))
|
(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 <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
|
||||||
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
`(("error" . "Could not connect to Icecast server"))
|
||||||
("title" . ,title)
|
:status 503)))))
|
||||||
("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
|
||||||
|
|
||||||
|
|
@ -1567,23 +1526,4 @@
|
||||||
;; 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))
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,7 @@
|
||||||
#:play-file
|
#:play-file
|
||||||
#:play-list
|
#:play-list
|
||||||
#:pipeline-server
|
#: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)
|
(in-package #:cl-streamer/harmony)
|
||||||
|
|
||||||
|
|
@ -104,20 +94,7 @@
|
||||||
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
|
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
|
||||||
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
|
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
|
||||||
(channels :initarg :channels :accessor pipeline-channels :initform 2)
|
(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")
|
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
|
||||||
(sample-rate 44100) (channels 2))
|
(sample-rate 44100) (channels 2))
|
||||||
|
|
@ -179,43 +156,6 @@
|
||||||
(log:info "Audio pipeline stopped")
|
(log:info "Audio pipeline stopped")
|
||||||
pipeline)
|
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)
|
(defun read-audio-metadata (file-path)
|
||||||
"Read metadata (artist, title, album) from an audio file using taglib.
|
"Read metadata (artist, title, album) from an audio file using taglib.
|
||||||
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
|
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
|
||||||
|
|
@ -250,15 +190,6 @@
|
||||||
(dolist (output (drain-outputs (pipeline-drain pipeline)))
|
(dolist (output (drain-outputs (pipeline-drain pipeline)))
|
||||||
(cl-streamer:set-now-playing (cdr output) display-title)))
|
(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)
|
(defun play-file (pipeline file-path &key (mixer :music) title (on-end :free)
|
||||||
(update-metadata t))
|
(update-metadata t))
|
||||||
"Play an audio file through the pipeline.
|
"Play an audio file through the pipeline.
|
||||||
|
|
@ -271,19 +202,12 @@
|
||||||
(let* ((path (pathname file-path))
|
(let* ((path (pathname file-path))
|
||||||
(server (pipeline-harmony-server pipeline))
|
(server (pipeline-harmony-server pipeline))
|
||||||
(harmony:*server* server)
|
(harmony:*server* server)
|
||||||
(tags (read-audio-metadata path))
|
(display-title (format-display-title path title)))
|
||||||
(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
|
(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)))
|
(let ((voice (harmony:play path :mixer mixer :on-end on-end)))
|
||||||
(log:info "Now playing: ~A" display-title)
|
(log:info "Now playing: ~A" display-title)
|
||||||
(values voice display-title track-info))))
|
(values voice display-title))))
|
||||||
|
|
||||||
(defun voice-remaining-seconds (voice)
|
(defun voice-remaining-seconds (voice)
|
||||||
"Return estimated seconds remaining for a voice, or NIL if unknown."
|
"Return estimated seconds remaining for a voice, or NIL if unknown."
|
||||||
|
|
@ -307,91 +231,64 @@
|
||||||
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
|
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
|
||||||
(sleep step-time))))
|
(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)
|
(defun play-list (pipeline file-list &key (crossfade-duration 3.0)
|
||||||
(fade-in 2.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.
|
"Play a list of file paths sequentially through the pipeline.
|
||||||
Each entry can be a string (path) or a plist (:file path :title title).
|
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).
|
CROSSFADE-DURATION is how early to start the next track (seconds).
|
||||||
FADE-IN/FADE-OUT control the volume ramp durations.
|
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
|
(bt:make-thread
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(let ((prev-voice nil)
|
(let ((prev-voice nil))
|
||||||
(idx 0)
|
(loop for entry in file-list
|
||||||
(remaining-list (list (copy-list file-list))))
|
for idx from 0
|
||||||
(loop while (pipeline-running-p pipeline)
|
while (pipeline-running-p pipeline)
|
||||||
for entry = (next-entry pipeline remaining-list)
|
do (multiple-value-bind (path title)
|
||||||
do (cond
|
(if (listp entry)
|
||||||
;; No entry and loop mode: wait for queue
|
(values (getf entry :file) (getf entry :title))
|
||||||
((and (null entry) loop-queue)
|
(values entry nil))
|
||||||
(sleep 1))
|
(handler-case
|
||||||
;; No entry: done
|
(let* ((server (pipeline-harmony-server pipeline))
|
||||||
((null entry)
|
(harmony:*server* server))
|
||||||
(return))
|
(multiple-value-bind (voice display-title)
|
||||||
;; Play the entry
|
(play-file pipeline path :title title
|
||||||
(t
|
:on-end :disconnect
|
||||||
(multiple-value-bind (path title)
|
:update-metadata (null prev-voice))
|
||||||
(if (listp entry)
|
(when voice
|
||||||
(values (getf entry :file) (getf entry :title))
|
;; If this isn't the first track, crossfade
|
||||||
(values entry nil))
|
(when (and prev-voice (> idx 0))
|
||||||
(handler-case
|
(setf (mixed:volume voice) 0.0)
|
||||||
(let* ((server (pipeline-harmony-server pipeline))
|
;; Fade in new voice and fade out old voice in parallel
|
||||||
(harmony:*server* server))
|
(let ((fade-thread
|
||||||
(multiple-value-bind (voice display-title track-info)
|
(bt:make-thread
|
||||||
(play-file pipeline path :title title
|
(lambda ()
|
||||||
:on-end :disconnect
|
(volume-ramp prev-voice 0.0 fade-out)
|
||||||
:update-metadata (null prev-voice))
|
(harmony:stop prev-voice))
|
||||||
(when voice
|
:name "cl-streamer-fadeout")))
|
||||||
;; If this isn't the first track, crossfade
|
(volume-ramp voice 1.0 fade-in)
|
||||||
(when (and prev-voice (> idx 0))
|
(bt:join-thread fade-thread))
|
||||||
(setf (mixed:volume voice) 0.0)
|
;; Now the crossfade is done, update metadata
|
||||||
(let ((fade-thread
|
(update-all-mounts-metadata pipeline display-title))
|
||||||
(bt:make-thread
|
;; Wait for track to approach its end
|
||||||
(lambda ()
|
(sleep 0.5)
|
||||||
(volume-ramp prev-voice 0.0 fade-out)
|
(loop while (and (pipeline-running-p pipeline)
|
||||||
(harmony:stop prev-voice))
|
(not (mixed:done-p voice)))
|
||||||
:name "cl-streamer-fadeout")))
|
for remaining = (voice-remaining-seconds voice)
|
||||||
(volume-ramp voice 1.0 fade-in)
|
when (and remaining
|
||||||
(bt:join-thread fade-thread))
|
(<= remaining crossfade-duration)
|
||||||
;; Crossfade done — now update metadata & notify
|
(not (mixed:done-p voice)))
|
||||||
(update-all-mounts-metadata pipeline display-title)
|
do (setf prev-voice voice)
|
||||||
(notify-track-change pipeline track-info))
|
(return)
|
||||||
;; Wait for track to approach its end (or skip)
|
do (sleep 0.1))
|
||||||
(setf (pipeline-skip-flag pipeline) nil)
|
;; If track ended naturally (no crossfade), clean up
|
||||||
(sleep 0.5)
|
(when (mixed:done-p voice)
|
||||||
(loop while (and (pipeline-running-p pipeline)
|
(harmony:stop voice)
|
||||||
(not (mixed:done-p voice))
|
(setf prev-voice nil)))))
|
||||||
(not (pipeline-skip-flag pipeline)))
|
(error (e)
|
||||||
for remaining = (voice-remaining-seconds voice)
|
(log:warn "Error playing ~A: ~A" path e)
|
||||||
when (and remaining
|
(sleep 1)))))
|
||||||
(<= remaining crossfade-duration)
|
|
||||||
(not (mixed:done-p voice)))
|
|
||||||
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))
|
|
||||||
(incf idx))))
|
|
||||||
(error (e)
|
|
||||||
(log:warn "Error playing ~A: ~A" path e)
|
|
||||||
(sleep 1)))))))
|
|
||||||
;; Clean up last voice
|
;; Clean up last voice
|
||||||
(when prev-voice
|
(when prev-voice
|
||||||
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
|
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@
|
||||||
(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)
|
||||||
|
|
|
||||||
|
|
@ -122,13 +122,7 @@
|
||||||
:external-format :latin-1)))
|
:external-format :latin-1)))
|
||||||
(handler-case
|
(handler-case
|
||||||
(let* ((request-line (read-line stream))
|
(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)
|
(multiple-value-bind (path wants-meta)
|
||||||
(parse-icy-request request-line headers)
|
(parse-icy-request request-line headers)
|
||||||
(let ((mount (gethash path (server-mounts server))))
|
(let ((mount (gethash path (server-mounts server))))
|
||||||
|
|
@ -184,13 +178,8 @@
|
||||||
(stream (client-stream client))
|
(stream (client-stream client))
|
||||||
(chunk-size 4096)
|
(chunk-size 4096)
|
||||||
(chunk (make-array chunk-size :element-type '(unsigned-byte 8))))
|
(chunk (make-array chunk-size :element-type '(unsigned-byte 8))))
|
||||||
;; For MP3, burst recent data for fast playback start.
|
;; Start from burst position for fast playback
|
||||||
;; For AAC, start from current position — AAC requires ADTS frame alignment
|
(setf (client-read-pos client) (buffer-burst-start buffer))
|
||||||
;; 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)
|
(loop while (client-active-p client)
|
||||||
do (multiple-value-bind (bytes-read new-pos)
|
do (multiple-value-bind (bytes-read new-pos)
|
||||||
(buffer-read-from buffer (client-read-pos client) chunk)
|
(buffer-read-from buffer (client-read-pos client) chunk)
|
||||||
|
|
@ -206,8 +195,7 @@
|
||||||
(write-sequence chunk stream :end bytes-read))
|
(write-sequence chunk stream :end bytes-read))
|
||||||
(force-output stream))
|
(force-output stream))
|
||||||
(error (e)
|
(error (e)
|
||||||
(log:warn "Client stream error on ~A: ~A"
|
(log:debug "Client stream error: ~A" e)
|
||||||
(mount-path mount) e)
|
|
||||||
(setf (client-active-p client) nil)
|
(setf (client-active-p client) nil)
|
||||||
(return)))))))))
|
(return)))))))))
|
||||||
|
|
||||||
|
|
@ -233,16 +221,6 @@
|
||||||
(incf (client-bytes-since-meta client) bytes-remaining)
|
(incf (client-bytes-since-meta client) bytes-remaining)
|
||||||
(setf pos length)))))))
|
(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)
|
(defun send-404 (stream path)
|
||||||
"Send a 404 response for unknown mount points."
|
"Send a 404 response for unknown mount points."
|
||||||
(format stream "HTTP/1.1 404 Not Found~C~C" #\Return #\Linefeed)
|
(format stream "HTTP/1.1 404 Not Found~C~C" #\Return #\Linefeed)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@ if ! docker info > /dev/null 2>&1; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
|
# Start services
|
||||||
echo "🔧 Starting postgres..."
|
echo "🔧 Starting services..."
|
||||||
docker compose up -d postgres
|
docker compose up -d
|
||||||
# docker compose up -d # Uncomment to start all services (Icecast + Liquidsoap)
|
|
||||||
|
|
||||||
# Wait and show status
|
# Wait and show status
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
@ -26,10 +25,8 @@ echo "📊 Service Status:"
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🎵 Asteroid Radio database is ready!"
|
echo "🎵 Asteroid Radio is now streaming!"
|
||||||
echo "📡 Streaming is handled by cl-streamer (start from Lisp REPL)"
|
echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
|
||||||
# Legacy Icecast URLs (no longer used):
|
echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
|
||||||
# echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
|
echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
|
||||||
# echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
|
echo "🔧 Admin Panel: http://localhost:8000/admin/"
|
||||||
# echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
|
|
||||||
# echo "🔧 Admin Panel: http://localhost:8000/admin/"
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@
|
||||||
|
|
||||||
echo "🛑 Stopping Asteroid Radio Docker Services..."
|
echo "🛑 Stopping Asteroid Radio Docker Services..."
|
||||||
|
|
||||||
# Stop services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
|
# Stop services
|
||||||
docker compose down postgres
|
docker compose down
|
||||||
# docker compose down # Uncomment to stop all services
|
|
||||||
|
|
||||||
# if we really need to clean everything and start fresh, run the
|
# if we really need to clean everything and start fresh, run the
|
||||||
# following commands:
|
# following commands:
|
||||||
|
|
|
||||||
|
|
@ -93,22 +93,23 @@
|
||||||
(: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 "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)
|
(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 status from Icecast server.
|
||||||
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."
|
Always polls both streams to keep recently played lists updated."
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
;; 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)))
|
||||||
(if now-playing-stats
|
(if now-playing-stats
|
||||||
(let* ((title (cdr (assoc :title now-playing-stats)))
|
(let* ((title (cdr (assoc :title now-playing-stats)))
|
||||||
(favorite-count (or (get-track-favorite-count title) 0)))
|
(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")
|
(setf (header "Content-Type") "text/html")
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
(load-template "partial/now-playing")
|
(load-template "partial/now-playing")
|
||||||
|
|
@ -127,7 +128,7 @@
|
||||||
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 "asteroid.mp3"))
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||||
(if now-playing-stats
|
(if now-playing-stats
|
||||||
(progn
|
(progn
|
||||||
(setf (header "Content-Type") "text/plain")
|
(setf (header "Content-Type") "text/plain")
|
||||||
|
|
@ -143,7 +144,7 @@
|
||||||
(register-web-listener)
|
(register-web-listener)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
(now-playing-stats (icecast-now-playing *stream-base-url* 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)))
|
||||||
(favorite-count (or (get-track-favorite-count title) 0))
|
(favorite-count (or (get-track-favorite-count title) 0))
|
||||||
|
|
|
||||||
|
|
@ -476,25 +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."
|
(let ((stats (fetch-icecast-stats)))
|
||||||
(if *harmony-pipeline*
|
(when stats
|
||||||
;; Get listener counts directly from cl-streamer
|
(let ((sources (parse-icecast-sources stats)))
|
||||||
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
(dolist (source sources)
|
||||||
(let ((listeners (cl-streamer:get-listener-count mount)))
|
(let ((mount (getf source :mount))
|
||||||
(when (and listeners (> listeners 0))
|
(listeners (getf source :listeners)))
|
||||||
(store-listener-snapshot mount listeners)
|
(when mount
|
||||||
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
|
(store-listener-snapshot mount listeners)
|
||||||
;; Fallback: poll Icecast
|
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners)))))))
|
||||||
(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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,12 @@
|
||||||
(defun get-current-mount-for-recently-played ()
|
(defun get-current-mount-for-recently-played ()
|
||||||
(let ((channel (get-current-channel))
|
(let ((channel (get-current-channel))
|
||||||
(quality (get-current-quality)))
|
(quality (get-current-quality)))
|
||||||
(cond
|
(if (= channel "shuffle")
|
||||||
((= quality "low") "asteroid.mp3")
|
"asteroid-shuffle.mp3"
|
||||||
((= quality "mp3") "asteroid.mp3")
|
(cond
|
||||||
(t "asteroid.aac"))))
|
((= quality "low") "asteroid-low.mp3")
|
||||||
|
((= quality "mp3") "asteroid.mp3")
|
||||||
|
(t "asteroid.aac")))))
|
||||||
|
|
||||||
;; Update recently played tracks display
|
;; Update recently played tracks display
|
||||||
(defun update-recently-played ()
|
(defun update-recently-played ()
|
||||||
|
|
@ -41,7 +43,7 @@
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
;; Radiance wraps API responses in a data envelope
|
;; Radiance wraps API responses in a data envelope
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
(if (and (= (ps:@ data status) "success")
|
(if (and (equal (ps:@ data status) "success")
|
||||||
(ps:@ data tracks)
|
(ps:@ data tracks)
|
||||||
(> (ps:@ data tracks length) 0))
|
(> (ps:@ data tracks length) 0))
|
||||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||||
|
|
|
||||||
|
|
@ -149,23 +149,28 @@
|
||||||
;; ========================================
|
;; ========================================
|
||||||
|
|
||||||
;; Get stream configuration for a given channel and quality
|
;; Get stream configuration for a given channel and quality
|
||||||
;; With cl-streamer, both channels use the same stream mounts -
|
;; Curated channel has multiple quality options, shuffle has only one
|
||||||
;; channel switching loads a different playlist server-side
|
|
||||||
(defun get-stream-config (stream-base-url channel quality)
|
(defun get-stream-config (stream-base-url channel quality)
|
||||||
(let ((config (ps:create
|
(let ((curated-config (ps:create
|
||||||
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
|
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
|
||||||
:type "audio/aac"
|
:type "audio/aac"
|
||||||
:format "AAC 96kbps Stereo"
|
:format "AAC 96kbps Stereo"
|
||||||
:mount "asteroid.aac")
|
:mount "asteroid.aac")
|
||||||
:mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3")
|
:mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3")
|
||||||
:type "audio/mpeg"
|
:type "audio/mpeg"
|
||||||
:format "MP3 128kbps Stereo"
|
:format "MP3 128kbps Stereo"
|
||||||
:mount "asteroid.mp3")
|
:mount "asteroid.mp3")
|
||||||
:low (ps:create :url (+ stream-base-url "/asteroid.mp3")
|
:low (ps:create :url (+ stream-base-url "/asteroid-low.mp3")
|
||||||
:type "audio/mpeg"
|
:type "audio/mpeg"
|
||||||
:format "MP3 128kbps Stereo"
|
:format "MP3 64kbps Stereo"
|
||||||
:mount "asteroid.mp3"))))
|
:mount "asteroid-low.mp3")))
|
||||||
(ps:getprop config quality)))
|
(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))))
|
||||||
|
|
||||||
;; Get current channel from selector or localStorage
|
;; Get current channel from selector or localStorage
|
||||||
(defun get-current-channel ()
|
(defun get-current-channel ()
|
||||||
|
|
@ -667,52 +672,101 @@
|
||||||
(defvar *reconnect-timeout* nil)
|
(defvar *reconnect-timeout* nil)
|
||||||
(defvar *is-reconnecting* false)
|
(defvar *is-reconnecting* false)
|
||||||
|
|
||||||
;; Reconnect stream - reuses existing audio element to preserve user gesture context
|
;; Reconnect stream - recreates audio element to fix wedged state
|
||||||
(defun reconnect-stream ()
|
(defun reconnect-stream ()
|
||||||
(ps:chain console (log "Reconnecting stream..."))
|
(ps:chain console (log "Reconnecting stream..."))
|
||||||
(show-status "🔄 Reconnecting..." false)
|
(show-status "🔄 Reconnecting..." false)
|
||||||
|
|
||||||
(let* ((audio (ps:chain document (get-element-by-id "persistent-audio")))
|
(let* ((container (ps:chain document (query-selector ".persistent-player")))
|
||||||
(source (ps:chain document (get-element-by-id "audio-source")))
|
(old-audio (ps:chain document (get-element-by-id "persistent-audio")))
|
||||||
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
|
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
|
||||||
(stream-channel (get-current-channel))
|
(stream-channel (get-current-channel))
|
||||||
(stream-quality (get-current-quality))
|
(stream-quality (get-current-quality))
|
||||||
(config (get-stream-config stream-base-url stream-channel stream-quality)))
|
(config (get-stream-config stream-base-url stream-channel stream-quality)))
|
||||||
|
|
||||||
(unless audio
|
(unless (and container old-audio)
|
||||||
(show-status "❌ Could not reconnect - reload page" true)
|
(show-status "❌ Could not reconnect - reload page" true)
|
||||||
(setf *is-reconnecting* false)
|
(setf *is-reconnecting* false)
|
||||||
(return-from reconnect-stream nil))
|
(return-from reconnect-stream nil))
|
||||||
|
|
||||||
(ps:chain console (log "Saving volume:" (ps:@ audio volume) "muted:" (ps:@ audio muted)))
|
;; 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))
|
||||||
|
|
||||||
;; Reset spectrum analyzer if it exists
|
;; Reset spectrum analyzer if it exists
|
||||||
(when (ps:@ window reset-spectrum-analyzer)
|
(when (ps:@ window reset-spectrum-analyzer)
|
||||||
(ps:chain window (reset-spectrum-analyzer)))
|
(ps:chain window (reset-spectrum-analyzer)))
|
||||||
|
|
||||||
;; Reload source on existing element (preserves user gesture context)
|
;; Stop and remove old audio
|
||||||
(ps:chain audio (pause))
|
(ps:chain old-audio (pause))
|
||||||
(if source
|
(setf (ps:@ old-audio src) "")
|
||||||
;; Update existing source element
|
(ps:chain old-audio (load))
|
||||||
(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
|
;; Create new audio element
|
||||||
(ps:chain audio (load))
|
(let ((new-audio (ps:chain document (create-element "audio"))))
|
||||||
(setf *is-reconnecting* false)
|
(setf (ps:@ new-audio id) "persistent-audio")
|
||||||
(set-timeout
|
(setf (ps:@ new-audio controls) true)
|
||||||
(lambda ()
|
(setf (ps:@ new-audio preload) "metadata")
|
||||||
(ps:chain audio (play)
|
(setf (ps:@ new-audio cross-origin) "anonymous")
|
||||||
(catch (lambda (error)
|
|
||||||
(ps:chain console (log "Reconnect play failed:" error))))))
|
;; Restore volume and muted state
|
||||||
200)))
|
(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)))
|
||||||
|
(progn
|
||||||
|
(setf *is-reconnecting* false)
|
||||||
|
(show-status "❌ Could not reconnect. Click play to try again." true)))))))
|
||||||
|
300)))))
|
||||||
|
|
||||||
;; Simple reconnect for popout player (just reload and play)
|
;; Simple reconnect for popout player (just reload and play)
|
||||||
(defun simple-reconnect (audio-element)
|
(defun simple-reconnect (audio-element)
|
||||||
|
|
|
||||||
|
|
@ -68,26 +68,18 @@
|
||||||
(values skip-ok reload-ok)))
|
(values skip-ok reload-ok)))
|
||||||
|
|
||||||
(defun load-scheduled-playlist (playlist-name)
|
(defun load-scheduled-playlist (playlist-name)
|
||||||
"Load a playlist by name and trigger playback.
|
"Load a playlist by name, copying it to stream-queue.m3u and triggering playback."
|
||||||
Uses Harmony pipeline when available, falls back to Liquidsoap."
|
|
||||||
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
|
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
|
||||||
(if (probe-file playlist-path)
|
(if (probe-file playlist-path)
|
||||||
(progn
|
(progn
|
||||||
(copy-playlist-to-stream-queue playlist-path)
|
(copy-playlist-to-stream-queue playlist-path)
|
||||||
(load-queue-from-m3u-file)
|
(load-queue-from-m3u-file)
|
||||||
(if *harmony-pipeline*
|
(multiple-value-bind (skip-ok reload-ok)
|
||||||
;; Use cl-streamer directly
|
(liquidsoap-reload-and-skip)
|
||||||
(let ((count (harmony-load-playlist playlist-path)))
|
(if (and reload-ok skip-ok)
|
||||||
(if count
|
(log:info "Scheduler loaded ~a" playlist-name)
|
||||||
(log:info "Scheduler loaded ~a (~a tracks via Harmony)" playlist-name count)
|
(log:error "Scheduler failed to switch to ~a (reload:~a skip:~a)"
|
||||||
(log:error "Scheduler failed to load ~a via Harmony" playlist-name)))
|
playlist-name reload-ok skip-ok)))
|
||||||
;; 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))))
|
|
||||||
t)
|
t)
|
||||||
(progn
|
(progn
|
||||||
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
;;;; 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)))
|
|
||||||
Loading…
Reference in New Issue