210 lines
8.4 KiB
Common Lisp
210 lines
8.4 KiB
Common Lisp
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
|
|
;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
|
|
|
|
(in-package :asteroid)
|
|
|
|
;;; Stream Queue Management
|
|
;;; The stream queue represents what will play on the main broadcast
|
|
|
|
(defvar *stream-queue* '() "List of track IDs queued for streaming")
|
|
(defvar *stream-history* '() "List of recently played track IDs")
|
|
(defvar *max-history-size* 50 "Maximum number of tracks to keep in history")
|
|
|
|
(defun get-stream-queue ()
|
|
"Get the current stream queue"
|
|
*stream-queue*)
|
|
|
|
(defun add-to-stream-queue (track-id &optional (position :end))
|
|
"Add a track to the stream queue at specified position (:end or :next)"
|
|
(case position
|
|
(:next (push track-id *stream-queue*))
|
|
(:end (setf *stream-queue* (append *stream-queue* (list track-id))))
|
|
(t (error "Position must be :next or :end")))
|
|
(regenerate-stream-playlist)
|
|
t)
|
|
|
|
(defun remove-from-stream-queue (track-id)
|
|
"Remove a track from the stream queue"
|
|
(setf *stream-queue* (remove track-id *stream-queue* :test #'equal))
|
|
(regenerate-stream-playlist)
|
|
t)
|
|
|
|
(defun clear-stream-queue ()
|
|
"Clear the entire stream queue"
|
|
(setf *stream-queue* '())
|
|
(regenerate-stream-playlist)
|
|
t)
|
|
|
|
(defun reorder-stream-queue (track-ids)
|
|
"Reorder the stream queue with a new list of track IDs"
|
|
(setf *stream-queue* track-ids)
|
|
(regenerate-stream-playlist)
|
|
t)
|
|
|
|
(defun add-playlist-to-stream-queue (playlist-id)
|
|
"Add all tracks from a playlist to the stream queue"
|
|
(let ((playlist (get-playlist-by-id playlist-id)))
|
|
(when playlist
|
|
(let* ((track-ids-str (dm:field playlist "track-ids"))
|
|
(track-ids (if (and track-ids-str
|
|
(stringp track-ids-str)
|
|
(not (string= track-ids-str "")))
|
|
(mapcar #'parse-integer
|
|
(cl-ppcre:split "," track-ids-str))
|
|
nil)))
|
|
(dolist (track-id track-ids)
|
|
(add-to-stream-queue track-id :end))
|
|
t))))
|
|
|
|
;;; M3U Playlist Generation
|
|
|
|
(defun get-track-file-path (track-id)
|
|
"Get the file path for a track by ID"
|
|
(let ((track (get-track-by-id track-id)))
|
|
(when track
|
|
(dm:field track "file-path"))))
|
|
|
|
(defun convert-to-docker-path (host-path)
|
|
"Convert host file path to Docker container path"
|
|
;; Replace the music library path with /app/music/
|
|
(let ((library-prefix (namestring *music-library-path*)))
|
|
(if (and (stringp host-path)
|
|
(>= (length host-path) (length library-prefix))
|
|
(string= host-path library-prefix :end1 (length library-prefix)))
|
|
(format nil "/app/music/~a" (subseq host-path (length library-prefix)))
|
|
host-path)))
|
|
|
|
(defun generate-m3u-playlist (track-ids output-path)
|
|
"Generate an M3U playlist file from a list of track IDs"
|
|
(with-open-file (stream output-path
|
|
:direction :output
|
|
:if-exists :supersede
|
|
:if-does-not-exist :create)
|
|
(format stream "#EXTM3U~%")
|
|
(dolist (track-id track-ids)
|
|
(let ((file-path (get-track-file-path track-id)))
|
|
(when file-path
|
|
(let ((docker-path (convert-to-docker-path file-path)))
|
|
(format stream "#EXTINF:0,~%")
|
|
(format stream "~a~%" docker-path))))))
|
|
t)
|
|
|
|
(defun regenerate-stream-playlist ()
|
|
"Regenerate the main stream playlist from the current queue.
|
|
NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u
|
|
which is what Liquidsoap actually reads. This function may be deprecated."
|
|
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
|
(asdf:system-source-directory :asteroid))))
|
|
(if (null *stream-queue*)
|
|
;; DISABLED: Don't dump all tracks when queue is empty
|
|
;; This was overwriting files with all library tracks unexpectedly
|
|
;; (let ((all-tracks (dm:get "tracks" (db:query :all))))
|
|
;; (generate-m3u-playlist
|
|
;; (mapcar (lambda (track)
|
|
;; (dm:id track))
|
|
;; all-tracks)
|
|
;; playlist-path))
|
|
(format t "Stream queue is empty, not generating playlist file~%")
|
|
;; Generate from queue
|
|
(generate-m3u-playlist *stream-queue* playlist-path))))
|
|
|
|
(defun export-playlist-to-m3u (playlist-id output-path)
|
|
"Export a user playlist to an M3U file"
|
|
(let ((playlist (get-playlist-by-id playlist-id)))
|
|
(when playlist
|
|
(let* ((track-ids-str (dm:field playlist "track-ids"))
|
|
(track-ids (if (and track-ids-str
|
|
(stringp track-ids-str)
|
|
(not (string= track-ids-str "")))
|
|
(mapcar #'parse-integer
|
|
(cl-ppcre:split "," track-ids-str))
|
|
nil)))
|
|
(generate-m3u-playlist track-ids output-path)))))
|
|
|
|
;;; Stream History Management
|
|
(defun add-to-stream-history (track-id)
|
|
"Add a track to the stream history"
|
|
(push track-id *stream-history*)
|
|
;; Keep history size limited
|
|
(when (> (length *stream-history*) *max-history-size*)
|
|
(setf *stream-history* (subseq *stream-history* 0 *max-history-size*)))
|
|
t)
|
|
|
|
(defun get-stream-history (&optional (count 10))
|
|
"Get recent stream history (default 10 tracks)"
|
|
(subseq *stream-history* 0 (min count (length *stream-history*))))
|
|
|
|
;;; Smart Queue Building
|
|
|
|
(defun build-smart-queue (genre &optional (count 20))
|
|
"Build a smart queue based on genre"
|
|
(let ((tracks (dm:get "tracks" (db:query :all))))
|
|
;; For now, just add random tracks
|
|
;; TODO: Implement genre filtering when we have genre metadata
|
|
(let ((track-ids (mapcar (lambda (track)
|
|
(dm:id track))
|
|
tracks)))
|
|
(setf *stream-queue* (subseq (alexandria:shuffle track-ids)
|
|
0
|
|
(min count (length track-ids))))
|
|
(regenerate-stream-playlist)
|
|
*stream-queue*)))
|
|
|
|
(defun build-queue-from-artist (artist-name &optional (count 20))
|
|
"Build a queue from tracks by a specific artist"
|
|
(let ((tracks (dm:get "tracks" (db:query :all))))
|
|
(let ((matching-tracks
|
|
(remove-if-not
|
|
(lambda (track)
|
|
(let ((artist (dm:field track "artist")))
|
|
(when artist
|
|
(search artist-name artist :test #'char-equal))))
|
|
tracks)))
|
|
(let ((track-ids (mapcar (lambda (track)
|
|
(dm:id track))
|
|
matching-tracks)))
|
|
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
|
|
(regenerate-stream-playlist)
|
|
*stream-queue*))))
|
|
|
|
(defun convert-from-docker-path (docker-path)
|
|
"Convert Docker container path back to host file path"
|
|
(if (and (stringp docker-path)
|
|
(>= (length docker-path) 11)
|
|
(string= docker-path "/app/music/" :end1 11))
|
|
(format nil "~a~a"
|
|
(namestring *music-library-path*)
|
|
(subseq docker-path 11))
|
|
docker-path))
|
|
|
|
(defun load-queue-from-m3u-file ()
|
|
"Load the stream queue from the stream-queue.m3u file"
|
|
(let* ((m3u-path (merge-pathnames "stream-queue.m3u"
|
|
(asdf:system-source-directory :asteroid)))
|
|
(track-ids '())
|
|
(all-tracks (dm:get "tracks" (db:query :all))))
|
|
|
|
(when (probe-file m3u-path)
|
|
(with-open-file (stream m3u-path :direction :input)
|
|
(loop for line = (read-line stream nil)
|
|
while line
|
|
do (unless (or (string= line "")
|
|
(char= (char line 0) #\#))
|
|
;; This is a file path line
|
|
(let* ((docker-path (string-trim '(#\Space #\Tab #\Return #\Newline) line))
|
|
(host-path (convert-from-docker-path docker-path)))
|
|
;; Find track by file path
|
|
(let ((track (find-if
|
|
(lambda (trk)
|
|
(let ((file-path (dm:field trk "file-path")))
|
|
(string= file-path host-path)))
|
|
all-tracks)))
|
|
(when track
|
|
(push (dm:id track) track-ids))))))))
|
|
|
|
;; Reverse to maintain order from file
|
|
(setf track-ids (nreverse track-ids))
|
|
(setf *stream-queue* track-ids)
|
|
(regenerate-stream-playlist)
|
|
(length track-ids)))
|