Integrate cl-streamer into Asteroid Radio (replaces Icecast + Liquidsoap)

New files:
- stream-harmony.lisp: Bridge between cl-streamer pipeline and Asteroid app
  - start-harmony-streaming / stop-harmony-streaming lifecycle
  - on-harmony-track-change callback: feeds recently-played, DB track lookup
  - harmony-now-playing: returns same alist format as icecast-now-playing
  - harmony-load-playlist: loads M3U, converts Docker paths, feeds queue
  - harmony-skip-track / harmony-get-status

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

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

Docker scripts updated:
- start.sh / stop.sh: only start/stop postgres (cl-streamer replaces streaming)
This commit is contained in:
Glenn Thompson 2026-03-03 21:27:29 +03:00
parent edf9326007
commit dad1418bf8
8 changed files with 472 additions and 120 deletions

View File

@ -29,6 +29,11 @@
: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
@ -64,6 +69,7 @@
(: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")

View File

@ -442,11 +442,13 @@
;; 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 current track to trigger crossfade to new playlist ;; Skip/switch to new playlist
(handler-case (if *harmony-pipeline*
(liquidsoap-command "stream-queue_m3u.skip") (harmony-load-playlist playlist-path)
(error (e) (handler-case
(format *error-output* "Warning: Could not skip track: ~a~%" e))) (liquidsoap-command "stream-queue_m3u.skip")
(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)
@ -568,48 +570,74 @@
(error () seconds-str))) (error () seconds-str)))
(define-api asteroid/liquidsoap/status () () (define-api asteroid/liquidsoap/status () ()
"Get Liquidsoap status including uptime and current track" "Get stream status - uses Harmony pipeline when available, falls back to Liquidsoap"
(require-role :admin) (require-role :admin)
(with-error-handling (with-error-handling
(let ((uptime (liquidsoap-command "uptime")) (if *harmony-pipeline*
(metadata-raw (liquidsoap-command "output.icecast.1.metadata")) (let ((status (harmony-get-status)))
(remaining-raw (liquidsoap-command "output.icecast.1.remaining"))) (api-output `(("status" . "success")
(api-output `(("status" . "success") ("backend" . "harmony")
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime)) ("uptime" . "n/a")
("metadata" . ,(parse-liquidsoap-metadata metadata-raw)) ("metadata" . ,(getf status :current-track))
("remaining" . ,(format-remaining-time ("remaining" . "n/a")
(string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))) ("listeners" . ,(getf status :listeners))
("queue_length" . ,(getf status :queue-length)))))
(let ((uptime (liquidsoap-command "uptime"))
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
(api-output `(("status" . "success")
("backend" . "liquidsoap")
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
("remaining" . ,(format-remaining-time
(string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))))
(define-api asteroid/liquidsoap/skip () () (define-api asteroid/liquidsoap/skip () ()
"Skip the current track in Liquidsoap" "Skip the current track"
(require-role :admin) (require-role :admin)
(with-error-handling (with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.skip"))) (if *harmony-pipeline*
(api-output `(("status" . "success") (progn
("message" . "Track skipped") (harmony-skip-track)
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))) (api-output `(("status" . "success")
("message" . "Track skipped (Harmony)"))))
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
(api-output `(("status" . "success")
("message" . "Track skipped")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
(define-api asteroid/liquidsoap/reload () () (define-api asteroid/liquidsoap/reload () ()
"Force Liquidsoap to reload the playlist" "Force playlist reload"
(require-role :admin) (require-role :admin)
(with-error-handling (with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.reload"))) (if *harmony-pipeline*
(api-output `(("status" . "success") (let* ((playlist-path (get-stream-queue-path))
("message" . "Playlist reloaded") (count (harmony-load-playlist playlist-path)))
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))) (api-output `(("status" . "success")
("message" . ,(format nil "Playlist reloaded (~A tracks via Harmony)" count)))))
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
(api-output `(("status" . "success")
("message" . "Playlist reloaded")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
(define-api asteroid/liquidsoap/restart () () (define-api asteroid/liquidsoap/restart () ()
"Restart the Liquidsoap Docker container" "Restart the streaming backend"
(require-role :admin) (require-role :admin)
(with-error-handling (with-error-handling
(let ((result (uiop:run-program (if *harmony-pipeline*
"docker restart asteroid-liquidsoap" (progn
:output :string (stop-harmony-streaming)
:error-output :string (start-harmony-streaming)
:ignore-error-status t))) (api-output `(("status" . "success")
(api-output `(("status" . "success") ("message" . "Harmony pipeline restarted"))))
("message" . "Liquidsoap container restarting") (let ((result (uiop:run-program
("result" . ,result)))))) "docker restart asteroid-liquidsoap"
: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"

View File

@ -10,7 +10,17 @@
#: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)
@ -94,7 +104,20 @@
(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))
@ -156,6 +179,43 @@
(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."
@ -190,6 +250,15 @@
(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.
@ -202,12 +271,19 @@
(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)
(display-title (format-display-title path title))) (tags (read-audio-metadata path))
(display-title (format-display-title path title))
(track-info (list :file (namestring path)
:display-title display-title
:artist (getf tags :artist)
:title (getf tags :title)
:album (getf tags :album))))
(when update-metadata (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)))) (values voice display-title track-info))))
(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."
@ -231,64 +307,91 @@
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)
(loop for entry in file-list (idx 0)
for idx from 0 (remaining-list (list (copy-list file-list))))
while (pipeline-running-p pipeline) (loop while (pipeline-running-p pipeline)
do (multiple-value-bind (path title) for entry = (next-entry pipeline remaining-list)
(if (listp entry) do (cond
(values (getf entry :file) (getf entry :title)) ;; No entry and loop mode: wait for queue
(values entry nil)) ((and (null entry) loop-queue)
(handler-case (sleep 1))
(let* ((server (pipeline-harmony-server pipeline)) ;; No entry: done
(harmony:*server* server)) ((null entry)
(multiple-value-bind (voice display-title) (return))
(play-file pipeline path :title title ;; Play the entry
:on-end :disconnect (t
:update-metadata (null prev-voice)) (multiple-value-bind (path title)
(when voice (if (listp entry)
;; If this isn't the first track, crossfade (values (getf entry :file) (getf entry :title))
(when (and prev-voice (> idx 0)) (values entry nil))
(setf (mixed:volume voice) 0.0) (handler-case
;; Fade in new voice and fade out old voice in parallel (let* ((server (pipeline-harmony-server pipeline))
(let ((fade-thread (harmony:*server* server))
(bt:make-thread (multiple-value-bind (voice display-title track-info)
(lambda () (play-file pipeline path :title title
(volume-ramp prev-voice 0.0 fade-out) :on-end :disconnect
(harmony:stop prev-voice)) :update-metadata (null prev-voice))
:name "cl-streamer-fadeout"))) (when voice
(volume-ramp voice 1.0 fade-in) ;; If this isn't the first track, crossfade
(bt:join-thread fade-thread)) (when (and prev-voice (> idx 0))
;; Now the crossfade is done, update metadata (setf (mixed:volume voice) 0.0)
(update-all-mounts-metadata pipeline display-title)) (let ((fade-thread
;; Wait for track to approach its end (bt:make-thread
(sleep 0.5) (lambda ()
(loop while (and (pipeline-running-p pipeline) (volume-ramp prev-voice 0.0 fade-out)
(not (mixed:done-p voice))) (harmony:stop prev-voice))
for remaining = (voice-remaining-seconds voice) :name "cl-streamer-fadeout")))
when (and remaining (volume-ramp voice 1.0 fade-in)
(<= remaining crossfade-duration) (bt:join-thread fade-thread))
(not (mixed:done-p voice))) ;; Crossfade done — now update metadata & notify
do (setf prev-voice voice) (update-all-mounts-metadata pipeline display-title)
(return) (notify-track-change pipeline track-info))
do (sleep 0.1)) ;; Wait for track to approach its end (or skip)
;; If track ended naturally (no crossfade), clean up (setf (pipeline-skip-flag pipeline) nil)
(when (mixed:done-p voice) (sleep 0.5)
(harmony:stop voice) (loop while (and (pipeline-running-p pipeline)
(setf prev-voice nil))))) (not (mixed:done-p voice))
(error (e) (not (pipeline-skip-flag pipeline)))
(log:warn "Error playing ~A: ~A" path e) for remaining = (voice-remaining-seconds voice)
(sleep 1))))) when (and remaining
(<= 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)))

View File

@ -14,9 +14,10 @@ if ! docker info > /dev/null 2>&1; then
exit 1 exit 1
fi fi
# Start services # Start services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
echo "🔧 Starting services..." echo "🔧 Starting postgres..."
docker compose up -d docker compose up -d postgres
# docker compose up -d # Uncomment to start all services (Icecast + Liquidsoap)
# Wait and show status # Wait and show status
sleep 3 sleep 3
@ -25,8 +26,10 @@ echo "📊 Service Status:"
docker compose ps docker compose ps
echo "" echo ""
echo "🎵 Asteroid Radio is now streaming!" echo "🎵 Asteroid Radio database is ready!"
echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3" echo "📡 Streaming is handled by cl-streamer (start from Lisp REPL)"
echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac" # Legacy Icecast URLs (no longer used):
echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3" # echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
echo "🔧 Admin Panel: http://localhost:8000/admin/" # echo "📡 High Quality AAC: http://localhost:8000/asteroid.aac"
# echo "📡 Low Quality MP3: http://localhost:8000/asteroid-low.mp3"
# echo "🔧 Admin Panel: http://localhost:8000/admin/"

View File

@ -5,8 +5,9 @@
echo "🛑 Stopping Asteroid Radio Docker Services..." echo "🛑 Stopping Asteroid Radio Docker Services..."
# Stop services # Stop services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
docker compose down docker compose down postgres
# 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:

View File

@ -93,23 +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 "stream.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))))
(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 status from Icecast server. "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.
Always polls both streams to keep recently played lists updated." Uses Harmony pipeline when available, falls back to Icecast."
(with-error-handling (with-error-handling
(let* ((mount-name (or mount "asteroid.mp3")) (let* ((mount-name (or mount "stream.mp3"))
;; Always poll both streams to keep recently played lists updated (now-playing-stats (get-now-playing-stats mount-name)))
(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,8 +127,8 @@
"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 "asteroid.mp3")) (let* ((mount-name (or mount "stream.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats (if now-playing-stats
(progn (progn
(setf (header "Content-Type") "text/plain") (setf (header "Content-Type") "text/plain")
@ -143,8 +143,8 @@
;; 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 "asteroid.mp3")) (let* ((mount-name (or mount "stream.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (now-playing-stats (get-now-playing-stats mount-name)))
(if now-playing-stats (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))

View File

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

203
stream-harmony.lisp Normal file
View File

@ -0,0 +1,203 @@
;;;; 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 "stream.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
(format nil "/~A" mount)))
(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* "/stream.mp3"
:content-type "audio/mpeg"
:bitrate 128
:name "Asteroid Radio MP3")
(cl-streamer:add-mount cl-streamer:*server* "/stream.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 "/stream.mp3"))
;; Add AAC output
(cl-streamer/harmony:add-pipeline-output *harmony-pipeline*
*harmony-aac-encoder*
"/stream.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)))