;;;; stream-harmony.lisp - CL-Streamer / Harmony integration for Asteroid Radio ;;;; In-process audio streaming via Harmony + cl-streamer. ;;;; 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.") (defvar *harmony-state-file* (merge-pathnames ".playback-state.lisp" (asdf:system-source-directory :asteroid)) "File to persist current playback position across restarts.") ;;; ---- Playback State Persistence ---- (defvar *current-playlist-path* nil "Path of the currently active playlist file.") (defun save-playback-state (track-file-path) "Save the current track file path and playlist to the state file. Called on each track change so we can resume after restart." (handler-case (with-open-file (s *harmony-state-file* :direction :output :if-exists :supersede :if-does-not-exist :create) (prin1 (list :track-file track-file-path :playlist (when *current-playlist-path* (namestring *current-playlist-path*)) :timestamp (get-universal-time)) s)) (error (e) (log:warn "Could not save playback state: ~A" e)))) (defun load-playback-state () "Load the saved playback state. Returns plist or NIL." (handler-case (when (probe-file *harmony-state-file*) (with-open-file (s *harmony-state-file* :direction :input) (read s nil nil))) (error (e) (log:warn "Could not load playback state: ~A" e) nil))) (defun resume-from-saved-state () "Load saved playback state, resolve the correct playlist, and return (values file-list playlist-path) starting after the saved track. Returns NIL if no state or playlist found." (let ((state (load-playback-state))) (when state (let* ((saved-file (getf state :track-file)) (saved-playlist (getf state :playlist)) ;; Use saved playlist if it exists, otherwise fall back to current scheduled (playlist-path (or (and saved-playlist (probe-file (pathname saved-playlist))) (let ((scheduled (get-current-scheduled-playlist))) (when scheduled (let ((p (merge-pathnames scheduled (get-playlists-directory)))) (probe-file p)))))) (file-list (when playlist-path (m3u-to-file-list playlist-path)))) (when file-list (setf *current-playlist-path* playlist-path) (let ((pos (when saved-file (position saved-file file-list :test #'string=)))) (if pos (let ((remaining (nthcdr (1+ pos) file-list))) (if remaining (progn (log:info "Resuming after track ~A (~A of ~A) from ~A" (file-namestring saved-file) (1+ pos) (length file-list) (file-namestring playlist-path)) (values remaining playlist-path)) (progn (log:info "Last saved track was final, restarting ~A" (file-namestring playlist-path)) (values file-list playlist-path)))) (progn (log:info "Saved track not found, starting ~A from beginning" (file-namestring playlist-path)) (values file-list playlist-path))))))))) ;;; ---- 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 & Playlist Change Callbacks ---- (defun on-harmony-playlist-change (pipeline playlist-path) "Called by cl-streamer when a scheduler playlist actually starts playing. Updates *current-playlist-path* only now, not at queue time." (declare (ignore pipeline)) (setf *current-playlist-path* playlist-path) (log:info "Playlist now active: ~A" (file-namestring playlist-path))) (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)) ;; Persist current track for resume-on-restart (when file-path (save-playback-state file-path)) (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 now-playing data from cl-streamer's pipeline state. (defun harmony-now-playing (&optional (mount "asteroid.mp3")) "Get now-playing information from cl-streamer pipeline. Returns an alist with now-playing data, or NIL if the pipeline is not running." (when (and *harmony-pipeline* (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*)) (let* ((track-info (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*)) (display-title (or (getf track-info :display-title) "Unknown")) (listeners (cl-streamer:get-listener-count)) (track-id (or (find-track-by-title display-title) (find-track-by-file-path (getf track-info :file))))) `((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount)) (:title . ,display-title) (:listeners . ,(or listeners 0)) (:track-id . ,track-id) (:favorite-count . ,(or (get-track-favorite-count display-title) 0)))))) ;;; ---- Pipeline Lifecycle ---- (defun start-harmony-streaming (&key (port *harmony-stream-port*) (mp3-bitrate 128000) (aac-bitrate 128000)) "Start the cl-streamer pipeline with MP3 and AAC outputs. Should be called once during application startup." (when *harmony-pipeline* (log:warn "Harmony streaming already running") (return-from start-harmony-streaming *harmony-pipeline*)) ;; Start the stream server (cl-streamer:start :port port) ;; Add mount points (cl-streamer:add-mount cl-streamer:*server* "/asteroid.mp3" :content-type "audio/mpeg" :bitrate 128 :name "Asteroid Radio MP3") (cl-streamer:add-mount cl-streamer:*server* "/asteroid.aac" :content-type "audio/aac" :bitrate 128 :name "Asteroid Radio AAC") ;; Create encoders (setf *harmony-mp3-encoder* (cl-streamer:make-mp3-encoder :bitrate (floor mp3-bitrate 1000) :sample-rate 44100 :channels 2)) (setf *harmony-aac-encoder* (cl-streamer:make-aac-encoder :bitrate aac-bitrate :sample-rate 44100 :channels 2)) ;; Create pipeline with track-change callback (setf *harmony-pipeline* (cl-streamer/harmony:make-audio-pipeline :encoder *harmony-mp3-encoder* :stream-server cl-streamer:*server* :mount-path "/asteroid.mp3")) ;; Add AAC output (cl-streamer/harmony:add-pipeline-output *harmony-pipeline* *harmony-aac-encoder* "/asteroid.aac") ;; Set the track-change callback (setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*) #'on-harmony-track-change) ;; Set the playlist-change callback (fires when scheduler playlist actually starts) (setf (cl-streamer/harmony:pipeline-on-playlist-change *harmony-pipeline*) #'on-harmony-playlist-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 ---- (defun harmony-load-playlist (m3u-path &key (skip nil)) "Load and start playing an M3U playlist through the Harmony pipeline. Converts Docker paths to host paths and feeds them to play-list. When SKIP is T, immediately crossfade to the new playlist. When SKIP is NIL (default), queue tracks to play after the current track." (when *harmony-pipeline* (let ((file-list (m3u-to-file-list m3u-path))) (when file-list ;; Store pending playlist path on pipeline — it will be applied ;; when drain-queue-into-remaining fires and the new tracks ;; actually start playing, not now at queue time. (setf (cl-streamer/harmony:pipeline-pending-playlist-path *harmony-pipeline*) (pathname m3u-path)) ;; 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)) ;; Only skip if explicitly requested (when skip (cl-streamer/harmony:pipeline-skip *harmony-pipeline*)) (log:info "Loaded playlist ~A (~A tracks)" (file-namestring 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." (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)))