From dad1418bf802055ae29009a7d04d9965d0f422de Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Tue, 3 Mar 2026 21:27:29 +0300 Subject: [PATCH] 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) --- asteroid.asd | 6 + asteroid.lisp | 94 +++++++++----- cl-streamer/harmony-backend.lisp | 213 +++++++++++++++++++++++-------- docker/start.sh | 19 +-- docker/stop.sh | 5 +- frontend-partials.lisp | 30 ++--- playlist-scheduler.lisp | 22 +++- stream-harmony.lisp | 203 +++++++++++++++++++++++++++++ 8 files changed, 472 insertions(+), 120 deletions(-) create mode 100644 stream-harmony.lisp diff --git a/asteroid.asd b/asteroid.asd index c01fb35..a276aa8 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -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") diff --git a/asteroid.lisp b/asteroid.lisp index cbddc52..0e79ea6 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -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" diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp index 50d9383..b9d2a63 100644 --- a/cl-streamer/harmony-backend.lisp +++ b/cl-streamer/harmony-backend.lisp @@ -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))) diff --git a/docker/start.sh b/docker/start.sh index 263c654..f0e36dd 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -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/" diff --git a/docker/stop.sh b/docker/stop.sh index f695a86..e06857f 100755 --- a/docker/stop.sh +++ b/docker/stop.sh @@ -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: diff --git a/frontend-partials.lisp b/frontend-partials.lisp index a0f27d3..7641506 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -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 :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)) diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp index 0563006..944d5ce 100644 --- a/playlist-scheduler.lisp +++ b/playlist-scheduler.lisp @@ -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) diff --git a/stream-harmony.lisp b/stream-harmony.lisp new file mode 100644 index 0000000..924ca37 --- /dev/null +++ b/stream-harmony.lisp @@ -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)))