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:
parent
edf9326007
commit
dad1418bf8
|
|
@ -29,6 +29,11 @@
|
|||
:bordeaux-threads
|
||||
:drakma
|
||||
:cl-cron
|
||||
;; CL-Streamer (replaces Icecast + Liquidsoap)
|
||||
:cl-streamer
|
||||
:cl-streamer/encoder
|
||||
:cl-streamer/aac-encoder
|
||||
:cl-streamer/harmony
|
||||
;; radiance interfaces
|
||||
:i-log4cl
|
||||
:i-postmodern
|
||||
|
|
@ -64,6 +69,7 @@
|
|||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "stream-harmony")
|
||||
(:file "playlist-scheduler")
|
||||
(:file "listener-stats")
|
||||
(:file "user-profile")
|
||||
|
|
|
|||
|
|
@ -442,11 +442,13 @@
|
|||
;; Load into in-memory queue
|
||||
(let ((count (load-queue-from-m3u-file))
|
||||
(channel-name (get-curated-channel-name)))
|
||||
;; Skip current track to trigger crossfade to new playlist
|
||||
;; Skip/switch to new playlist
|
||||
(if *harmony-pipeline*
|
||||
(harmony-load-playlist playlist-path)
|
||||
(handler-case
|
||||
(liquidsoap-command "stream-queue_m3u.skip")
|
||||
(error (e)
|
||||
(format *error-output* "Warning: Could not skip track: ~a~%" e)))
|
||||
(format *error-output* "Warning: Could not skip track: ~a~%" e))))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "Loaded playlist: ~a" name))
|
||||
("count" . ,count)
|
||||
|
|
@ -568,40 +570,66 @@
|
|||
(error () seconds-str)))
|
||||
|
||||
(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)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(let ((status (harmony-get-status)))
|
||||
(api-output `(("status" . "success")
|
||||
("backend" . "harmony")
|
||||
("uptime" . "n/a")
|
||||
("metadata" . ,(getf status :current-track))
|
||||
("remaining" . "n/a")
|
||||
("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))))))))
|
||||
(string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/skip () ()
|
||||
"Skip the current track in Liquidsoap"
|
||||
"Skip the current track"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(progn
|
||||
(harmony-skip-track)
|
||||
(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)))))))
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/reload () ()
|
||||
"Force Liquidsoap to reload the playlist"
|
||||
"Force playlist reload"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(let* ((playlist-path (get-stream-queue-path))
|
||||
(count (harmony-load-playlist playlist-path)))
|
||||
(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)))))))
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/restart () ()
|
||||
"Restart the Liquidsoap Docker container"
|
||||
"Restart the streaming backend"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(progn
|
||||
(stop-harmony-streaming)
|
||||
(start-harmony-streaming)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Harmony pipeline restarted"))))
|
||||
(let ((result (uiop:run-program
|
||||
"docker restart asteroid-liquidsoap"
|
||||
:output :string
|
||||
|
|
@ -609,7 +637,7 @@
|
|||
:ignore-error-status t)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Liquidsoap container restarting")
|
||||
("result" . ,result))))))
|
||||
("result" . ,result)))))))
|
||||
|
||||
(define-api asteroid/icecast/restart () ()
|
||||
"Restart the Icecast Docker container"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,17 @@
|
|||
#:play-file
|
||||
#:play-list
|
||||
#: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)
|
||||
|
||||
|
|
@ -94,7 +104,20 @@
|
|||
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
|
||||
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
|
||||
(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")
|
||||
(sample-rate 44100) (channels 2))
|
||||
|
|
@ -156,6 +179,43 @@
|
|||
(log:info "Audio pipeline stopped")
|
||||
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)
|
||||
"Read metadata (artist, title, album) from an audio file using taglib.
|
||||
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
|
||||
|
|
@ -190,6 +250,15 @@
|
|||
(dolist (output (drain-outputs (pipeline-drain pipeline)))
|
||||
(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)
|
||||
(update-metadata t))
|
||||
"Play an audio file through the pipeline.
|
||||
|
|
@ -202,12 +271,19 @@
|
|||
(let* ((path (pathname file-path))
|
||||
(server (pipeline-harmony-server pipeline))
|
||||
(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
|
||||
(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)))
|
||||
(log:info "Now playing: ~A" display-title)
|
||||
(values voice display-title))))
|
||||
(values voice display-title track-info))))
|
||||
|
||||
(defun voice-remaining-seconds (voice)
|
||||
"Return estimated seconds remaining for a voice, or NIL if unknown."
|
||||
|
|
@ -231,28 +307,47 @@
|
|||
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
|
||||
(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)
|
||||
(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.
|
||||
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).
|
||||
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
|
||||
(lambda ()
|
||||
(let ((prev-voice nil))
|
||||
(loop for entry in file-list
|
||||
for idx from 0
|
||||
while (pipeline-running-p pipeline)
|
||||
do (multiple-value-bind (path title)
|
||||
(let ((prev-voice nil)
|
||||
(idx 0)
|
||||
(remaining-list (list (copy-list file-list))))
|
||||
(loop while (pipeline-running-p pipeline)
|
||||
for entry = (next-entry pipeline remaining-list)
|
||||
do (cond
|
||||
;; No entry and loop mode: wait for queue
|
||||
((and (null entry) loop-queue)
|
||||
(sleep 1))
|
||||
;; No entry: done
|
||||
((null entry)
|
||||
(return))
|
||||
;; Play the entry
|
||||
(t
|
||||
(multiple-value-bind (path title)
|
||||
(if (listp entry)
|
||||
(values (getf entry :file) (getf entry :title))
|
||||
(values entry nil))
|
||||
(handler-case
|
||||
(let* ((server (pipeline-harmony-server pipeline))
|
||||
(harmony:*server* server))
|
||||
(multiple-value-bind (voice display-title)
|
||||
(multiple-value-bind (voice display-title track-info)
|
||||
(play-file pipeline path :title title
|
||||
:on-end :disconnect
|
||||
:update-metadata (null prev-voice))
|
||||
|
|
@ -260,7 +355,6 @@
|
|||
;; If this isn't the first track, crossfade
|
||||
(when (and prev-voice (> idx 0))
|
||||
(setf (mixed:volume voice) 0.0)
|
||||
;; Fade in new voice and fade out old voice in parallel
|
||||
(let ((fade-thread
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
|
|
@ -269,12 +363,15 @@
|
|||
:name "cl-streamer-fadeout")))
|
||||
(volume-ramp voice 1.0 fade-in)
|
||||
(bt:join-thread fade-thread))
|
||||
;; Now the crossfade is done, update metadata
|
||||
(update-all-mounts-metadata pipeline display-title))
|
||||
;; Wait for track to approach its end
|
||||
;; Crossfade done — now update metadata & notify
|
||||
(update-all-mounts-metadata pipeline display-title)
|
||||
(notify-track-change pipeline track-info))
|
||||
;; Wait for track to approach its end (or skip)
|
||||
(setf (pipeline-skip-flag pipeline) nil)
|
||||
(sleep 0.5)
|
||||
(loop while (and (pipeline-running-p pipeline)
|
||||
(not (mixed:done-p voice)))
|
||||
(not (mixed:done-p voice))
|
||||
(not (pipeline-skip-flag pipeline)))
|
||||
for remaining = (voice-remaining-seconds voice)
|
||||
when (and remaining
|
||||
(<= remaining crossfade-duration)
|
||||
|
|
@ -282,13 +379,19 @@
|
|||
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)))))
|
||||
(setf prev-voice nil))
|
||||
(incf idx))))
|
||||
(error (e)
|
||||
(log:warn "Error playing ~A: ~A" path e)
|
||||
(sleep 1)))))
|
||||
(sleep 1)))))))
|
||||
;; Clean up last voice
|
||||
(when prev-voice
|
||||
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ if ! docker info > /dev/null 2>&1; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Start services
|
||||
echo "🔧 Starting services..."
|
||||
docker compose up -d
|
||||
# Start services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
|
||||
echo "🔧 Starting postgres..."
|
||||
docker compose up -d postgres
|
||||
# docker compose up -d # Uncomment to start all services (Icecast + Liquidsoap)
|
||||
|
||||
# Wait and show status
|
||||
sleep 3
|
||||
|
|
@ -25,8 +26,10 @@ echo "📊 Service Status:"
|
|||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "🎵 Asteroid Radio is now streaming!"
|
||||
echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
|
||||
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/"
|
||||
echo "🎵 Asteroid Radio database is ready!"
|
||||
echo "📡 Streaming is handled by cl-streamer (start from Lisp REPL)"
|
||||
# Legacy Icecast URLs (no longer used):
|
||||
# echo "📡 High Quality MP3: http://localhost:8000/asteroid.mp3"
|
||||
# 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/"
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
|
||||
echo "🛑 Stopping Asteroid Radio Docker Services..."
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
# Stop services (postgres only - cl-streamer replaces Icecast + Liquidsoap)
|
||||
docker compose down postgres
|
||||
# docker compose down # Uncomment to stop all services
|
||||
|
||||
# if we really need to clean everything and start fresh, run the
|
||||
# following commands:
|
||||
|
|
|
|||
|
|
@ -93,23 +93,23 @@
|
|||
(:track-id . ,(find-track-by-title title))
|
||||
(:favorite-count . ,(or (get-track-favorite-count title) 1))))))))
|
||||
|
||||
(defun get-now-playing-stats (&optional (mount "stream.mp3"))
|
||||
"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)
|
||||
"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.
|
||||
Always polls both streams to keep recently played lists updated."
|
||||
Uses Harmony pipeline when available, falls back to Icecast."
|
||||
(with-error-handling
|
||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||
;; 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)))
|
||||
(let* ((mount-name (or mount "stream.mp3"))
|
||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
||||
(if now-playing-stats
|
||||
(let* ((title (cdr (assoc :title now-playing-stats)))
|
||||
(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")
|
||||
(clip:process-to-string
|
||||
(load-template "partial/now-playing")
|
||||
|
|
@ -127,8 +127,8 @@
|
|||
"Get inline text with now playing info (for admin dashboard and widgets).
|
||||
Optional MOUNT parameter specifies which stream to get metadata from."
|
||||
(with-error-handling
|
||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||
(let* ((mount-name (or mount "stream.mp3"))
|
||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
||||
(if now-playing-stats
|
||||
(progn
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
|
|
@ -143,8 +143,8 @@
|
|||
;; Register web listener for geo stats (keeps listener active during playback)
|
||||
(register-web-listener)
|
||||
(with-error-handling
|
||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||
(let* ((mount-name (or mount "stream.mp3"))
|
||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
||||
(if now-playing-stats
|
||||
(let* ((title (cdr (assoc :title now-playing-stats)))
|
||||
(favorite-count (or (get-track-favorite-count title) 0))
|
||||
|
|
|
|||
|
|
@ -68,18 +68,26 @@
|
|||
(values skip-ok reload-ok)))
|
||||
|
||||
(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))))
|
||||
(if (probe-file playlist-path)
|
||||
(progn
|
||||
(copy-playlist-to-stream-queue playlist-path)
|
||||
(load-queue-from-m3u-file)
|
||||
(if *harmony-pipeline*
|
||||
;; Use cl-streamer directly
|
||||
(let ((count (harmony-load-playlist playlist-path)))
|
||||
(if count
|
||||
(log:info "Scheduler loaded ~a (~a tracks via Harmony)" playlist-name count)
|
||||
(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)))
|
||||
playlist-name reload-ok skip-ok))))
|
||||
t)
|
||||
(progn
|
||||
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
Loading…
Reference in New Issue