asteroid/stream-harmony.lisp

296 lines
12 KiB
Common Lisp

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