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
: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")

View File

@ -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
(handler-case
(liquidsoap-command "stream-queue_m3u.skip")
(error (e)
(format *error-output* "Warning: Could not skip track: ~a~%" e)))
;; 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))))
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded playlist: ~a" name))
("count" . ,count)
@ -568,48 +570,74 @@
(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
(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")
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
("remaining" . ,(format-remaining-time
(string-trim '(#\Space #\Newline #\Return) remaining-raw))))))))
(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)))))))))
(define-api asteroid/liquidsoap/skip () ()
"Skip the current track in Liquidsoap"
"Skip the current track"
(require-role :admin)
(with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
(api-output `(("status" . "success")
("message" . "Track skipped")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
(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))))))))
(define-api asteroid/liquidsoap/reload () ()
"Force Liquidsoap to reload the playlist"
"Force playlist reload"
(require-role :admin)
(with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
(api-output `(("status" . "success")
("message" . "Playlist reloaded")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
(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))))))))
(define-api asteroid/liquidsoap/restart () ()
"Restart the Liquidsoap Docker container"
"Restart the streaming backend"
(require-role :admin)
(with-error-handling
(let ((result (uiop:run-program
"docker restart asteroid-liquidsoap"
:output :string
:error-output :string
:ignore-error-status t)))
(api-output `(("status" . "success")
("message" . "Liquidsoap container restarting")
("result" . ,result))))))
(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
:error-output :string
:ignore-error-status t)))
(api-output `(("status" . "success")
("message" . "Liquidsoap container restarting")
("result" . ,result)))))))
(define-api asteroid/icecast/restart () ()
"Restart the Icecast Docker container"

View File

@ -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,64 +307,91 @@
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)
(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)
(play-file pipeline path :title title
:on-end :disconnect
:update-metadata (null prev-voice))
(when voice
;; 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 ()
(volume-ramp prev-voice 0.0 fade-out)
(harmony:stop prev-voice))
: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
(sleep 0.5)
(loop while (and (pipeline-running-p pipeline)
(not (mixed:done-p voice)))
for remaining = (voice-remaining-seconds voice)
when (and remaining
(<= remaining crossfade-duration)
(not (mixed:done-p voice)))
do (setf prev-voice voice)
(return)
do (sleep 0.1))
;; If track ended naturally (no crossfade), clean up
(when (mixed:done-p voice)
(harmony:stop voice)
(setf prev-voice nil)))))
(error (e)
(log:warn "Error playing ~A: ~A" path e)
(sleep 1)))))
(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 track-info)
(play-file pipeline path :title title
:on-end :disconnect
:update-metadata (null prev-voice))
(when voice
;; If this isn't the first track, crossfade
(when (and prev-voice (> idx 0))
(setf (mixed:volume voice) 0.0)
(let ((fade-thread
(bt:make-thread
(lambda ()
(volume-ramp prev-voice 0.0 fade-out)
(harmony:stop prev-voice))
:name "cl-streamer-fadeout")))
(volume-ramp voice 1.0 fade-in)
(bt:join-thread fade-thread))
;; 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 (pipeline-skip-flag pipeline)))
for remaining = (voice-remaining-seconds voice)
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
(when prev-voice
(let ((harmony:*server* (pipeline-harmony-server pipeline)))

View File

@ -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/"

View File

@ -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:

View File

@ -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))

View File

@ -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)
(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)))
(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))))
t)
(progn
(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)))