Fix playlist stalls, FLAC/taglib errors, and playback state resume
- Prevent play-list thread death on scheduler playlist change: drain-queue-into-remaining drains full scheduler queue at once, updates loop-queue reference so repeat replays correct playlist, top-level handler-case prevents thread from dying silently - Fix taglib SIMPLE-ARRAY CHARACTER type errors: ensure-simple-string coerces metadata strings and trims whitespace - Fix FLAC::FILE slot unbound errors: retry once with 200ms delay for transient init failures - Improve playback state persistence: save playlist path alongside track file so restart loads the correct playlist instead of always falling back to stream-queue.m3u - Startup now uses resume-from-saved-state to resolve saved playlist and track position, falls back to stream-queue.m3u only if no state
This commit is contained in:
parent
16da880822
commit
9ae7546466
|
|
@ -1572,17 +1572,23 @@
|
||||||
(handler-case
|
(handler-case
|
||||||
(progn
|
(progn
|
||||||
(start-harmony-streaming)
|
(start-harmony-streaming)
|
||||||
;; Load the current playlist and start playing (resume from saved position)
|
;; Load playlist and start playing (resume from saved position if available)
|
||||||
(let ((playlist-path (get-stream-queue-path)))
|
(multiple-value-bind (resumed-list playlist-path)
|
||||||
(when (probe-file playlist-path)
|
(resume-from-saved-state)
|
||||||
(let* ((file-list (m3u-to-file-list playlist-path))
|
;; Fall back to stream-queue.m3u if no saved state
|
||||||
(resumed-list (when file-list (resume-from-saved-state file-list))))
|
(unless resumed-list
|
||||||
|
(let ((queue-path (get-stream-queue-path)))
|
||||||
|
(when (probe-file queue-path)
|
||||||
|
(setf resumed-list (m3u-to-file-list queue-path))
|
||||||
|
(setf playlist-path queue-path)
|
||||||
|
(setf *current-playlist-path* queue-path))))
|
||||||
(when resumed-list
|
(when resumed-list
|
||||||
(cl-streamer/harmony:play-list *harmony-pipeline* resumed-list
|
(cl-streamer/harmony:play-list *harmony-pipeline* resumed-list
|
||||||
:crossfade-duration 3.0
|
:crossfade-duration 3.0
|
||||||
:loop-queue t)
|
:loop-queue t)
|
||||||
(format t "~A tracks loaded from stream-queue.m3u (~A remaining after resume)~%"
|
(format t "~A tracks loaded from ~A~%"
|
||||||
(length file-list) (length resumed-list))))))
|
(length resumed-list)
|
||||||
|
(if playlist-path (file-namestring playlist-path) "stream-queue.m3u"))))
|
||||||
(format t "📡 Stream: ~a/asteroid.mp3~%" *stream-base-url*)
|
(format t "📡 Stream: ~a/asteroid.mp3~%" *stream-base-url*)
|
||||||
(format t "📡 Stream: ~a/asteroid.aac~%" *stream-base-url*))
|
(format t "📡 Stream: ~a/asteroid.aac~%" *stream-base-url*))
|
||||||
(error (e)
|
(error (e)
|
||||||
|
|
|
||||||
|
|
@ -216,14 +216,19 @@
|
||||||
|
|
||||||
;;; ---- Metadata ----
|
;;; ---- Metadata ----
|
||||||
|
|
||||||
|
(defun ensure-simple-string (s)
|
||||||
|
"Coerce S to a simple-string if it's a string, or return NIL."
|
||||||
|
(when (stringp s)
|
||||||
|
(copy-seq (string-trim '(#\Space #\Nul) s))))
|
||||||
|
|
||||||
(defun read-audio-metadata (file-path)
|
(defun read-audio-metadata (file-path)
|
||||||
"Read metadata (artist, title, album) from an audio file using taglib.
|
"Read metadata (artist, title, album) from an audio file using taglib.
|
||||||
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
|
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
|
||||||
(handler-case
|
(handler-case
|
||||||
(let ((audio-file (audio-streams:open-audio-file (namestring file-path))))
|
(let ((audio-file (audio-streams:open-audio-file (namestring file-path))))
|
||||||
(list :artist (or (abstract-tag:artist audio-file) nil)
|
(list :artist (ensure-simple-string (abstract-tag:artist audio-file))
|
||||||
:title (or (abstract-tag:title audio-file) nil)
|
:title (ensure-simple-string (abstract-tag:title audio-file))
|
||||||
:album (or (abstract-tag:album audio-file) nil)))
|
:album (ensure-simple-string (abstract-tag:album audio-file))))
|
||||||
(error (e)
|
(error (e)
|
||||||
(log:debug "Could not read tags from ~A: ~A" file-path e)
|
(log:debug "Could not read tags from ~A: ~A" file-path e)
|
||||||
nil)))
|
nil)))
|
||||||
|
|
@ -309,17 +314,31 @@
|
||||||
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
|
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
|
||||||
(sleep step-time))))
|
(sleep step-time))))
|
||||||
|
|
||||||
(defun next-entry (pipeline file-list-ref)
|
(defun drain-queue-into-remaining (pipeline remaining-ref current-list-ref)
|
||||||
"Get the next entry to play: from queue first (scheduler priority), then file-list.
|
"If the scheduler has queued tracks, drain them all into remaining-ref,
|
||||||
FILE-LIST-REF is a cons cell whose car is the remaining file list.
|
replacing any current remaining tracks. Also update current-list-ref
|
||||||
When the scheduler queues a new playlist, it replaces the remaining file-list
|
so loop-queue replays the scheduler's playlist, not the original.
|
||||||
so the new playlist takes effect immediately.
|
Returns T if new tracks were loaded, NIL otherwise."
|
||||||
Returns an entry or NIL if nothing to play."
|
(let ((first (pipeline-pop-queue pipeline)))
|
||||||
(let ((queued (pipeline-pop-queue pipeline)))
|
(when first
|
||||||
(when queued
|
(let ((all-queued (list first)))
|
||||||
;; Scheduler queued new tracks — discard remaining file-list
|
;; Drain remaining queue entries
|
||||||
(setf (car file-list-ref) nil))
|
(loop for item = (pipeline-pop-queue pipeline)
|
||||||
(or queued (pop (car file-list-ref)))))
|
while item do (push item all-queued))
|
||||||
|
(setf all-queued (nreverse all-queued))
|
||||||
|
(log:info "Scheduler playlist taking over: ~A tracks" (length all-queued))
|
||||||
|
;; Replace remaining list and update current for loop-queue
|
||||||
|
(setf (car remaining-ref) all-queued)
|
||||||
|
(setf (car current-list-ref) (copy-list all-queued))
|
||||||
|
t))))
|
||||||
|
|
||||||
|
(defun next-entry (pipeline remaining-ref current-list-ref)
|
||||||
|
"Get the next entry to play. Checks scheduler queue first (drains all into remaining),
|
||||||
|
then pops from remaining-ref.
|
||||||
|
REMAINING-REF is a cons cell whose car is the remaining file list.
|
||||||
|
CURRENT-LIST-REF is a cons cell whose car is the full current playlist (for loop-queue)."
|
||||||
|
(drain-queue-into-remaining pipeline remaining-ref current-list-ref)
|
||||||
|
(pop (car remaining-ref)))
|
||||||
|
|
||||||
(defun play-list (pipeline file-list &key (crossfade-duration 3.0)
|
(defun play-list (pipeline file-list &key (crossfade-duration 3.0)
|
||||||
(fade-in 2.0)
|
(fade-in 2.0)
|
||||||
|
|
@ -334,16 +353,19 @@
|
||||||
Scheduler-queued tracks take priority over the repeat cycle."
|
Scheduler-queued tracks take priority over the repeat cycle."
|
||||||
(bt:make-thread
|
(bt:make-thread
|
||||||
(lambda ()
|
(lambda ()
|
||||||
|
(handler-case
|
||||||
(let ((prev-voice nil)
|
(let ((prev-voice nil)
|
||||||
(idx 0)
|
(idx 0)
|
||||||
(remaining-list (list (copy-list file-list))))
|
(remaining-list (list (copy-list file-list)))
|
||||||
|
(current-list (list (copy-list file-list))))
|
||||||
(loop while (pipeline-running-p pipeline)
|
(loop while (pipeline-running-p pipeline)
|
||||||
for entry = (next-entry pipeline remaining-list)
|
for entry = (next-entry pipeline remaining-list current-list)
|
||||||
do (cond
|
do (cond
|
||||||
;; No entry and loop mode: re-queue original playlist
|
;; No entry and loop mode: re-queue current playlist
|
||||||
((and (null entry) loop-queue)
|
((and (null entry) loop-queue)
|
||||||
(log:info "Playlist ended, repeating from start")
|
(log:info "Playlist ended, repeating from start (~A tracks)"
|
||||||
(setf (car remaining-list) (copy-list file-list)))
|
(length (car current-list)))
|
||||||
|
(setf (car remaining-list) (copy-list (car current-list))))
|
||||||
;; No entry: done
|
;; No entry: done
|
||||||
((null entry)
|
((null entry)
|
||||||
(return))
|
(return))
|
||||||
|
|
@ -357,9 +379,18 @@
|
||||||
(let* ((server (pipeline-harmony-server pipeline))
|
(let* ((server (pipeline-harmony-server pipeline))
|
||||||
(harmony:*server* server))
|
(harmony:*server* server))
|
||||||
(multiple-value-bind (voice display-title track-info)
|
(multiple-value-bind (voice display-title track-info)
|
||||||
|
(handler-case
|
||||||
(play-file pipeline path :title title
|
(play-file pipeline path :title title
|
||||||
:on-end :disconnect
|
:on-end :disconnect
|
||||||
:update-metadata (null prev-voice))
|
:update-metadata (null prev-voice))
|
||||||
|
(error (retry-err)
|
||||||
|
;; Retry once after brief delay for transient FLAC init errors
|
||||||
|
(log:debug "Retrying ~A after init error: ~A"
|
||||||
|
(pathname-name (pathname path)) retry-err)
|
||||||
|
(sleep 0.2)
|
||||||
|
(play-file pipeline path :title title
|
||||||
|
:on-end :disconnect
|
||||||
|
:update-metadata (null prev-voice))))
|
||||||
(when voice
|
(when voice
|
||||||
;; If this isn't the first track, crossfade
|
;; If this isn't the first track, crossfade
|
||||||
(when (and prev-voice (> idx 0))
|
(when (and prev-voice (> idx 0))
|
||||||
|
|
@ -415,7 +446,9 @@
|
||||||
(when prev-voice
|
(when prev-voice
|
||||||
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
|
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
|
||||||
(volume-ramp prev-voice 0.0 fade-out)
|
(volume-ramp prev-voice 0.0 fade-out)
|
||||||
(harmony:stop prev-voice)))))
|
(harmony:stop prev-voice))))
|
||||||
|
(error (e)
|
||||||
|
(log:error "play-list thread crashed: ~A" e))))
|
||||||
:name "cl-streamer-playlist"))
|
:name "cl-streamer-playlist"))
|
||||||
|
|
||||||
(declaim (inline float-to-s16))
|
(declaim (inline float-to-s16))
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,11 @@
|
||||||
|
|
||||||
;;; ---- Playback State Persistence ----
|
;;; ---- Playback State Persistence ----
|
||||||
|
|
||||||
|
(defvar *current-playlist-path* nil
|
||||||
|
"Path of the currently active playlist file.")
|
||||||
|
|
||||||
(defun save-playback-state (track-file-path)
|
(defun save-playback-state (track-file-path)
|
||||||
"Save the current track file path to the state file.
|
"Save the current track file path and playlist to the state file.
|
||||||
Called on each track change so we can resume after restart."
|
Called on each track change so we can resume after restart."
|
||||||
(handler-case
|
(handler-case
|
||||||
(with-open-file (s *harmony-state-file*
|
(with-open-file (s *harmony-state-file*
|
||||||
|
|
@ -33,6 +36,8 @@
|
||||||
:if-exists :supersede
|
:if-exists :supersede
|
||||||
:if-does-not-exist :create)
|
:if-does-not-exist :create)
|
||||||
(prin1 (list :track-file track-file-path
|
(prin1 (list :track-file track-file-path
|
||||||
|
:playlist (when *current-playlist-path*
|
||||||
|
(namestring *current-playlist-path*))
|
||||||
:timestamp (get-universal-time))
|
:timestamp (get-universal-time))
|
||||||
s))
|
s))
|
||||||
(error (e)
|
(error (e)
|
||||||
|
|
@ -48,29 +53,43 @@
|
||||||
(log:warn "Could not load playback state: ~A" e)
|
(log:warn "Could not load playback state: ~A" e)
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
(defun resume-from-saved-state (file-list)
|
(defun resume-from-saved-state ()
|
||||||
"Given a playlist FILE-LIST, find the saved track and return the list
|
"Load saved playback state, resolve the correct playlist, and return
|
||||||
starting from the NEXT track after it. Returns the full list if no
|
(values file-list playlist-path) starting after the saved track.
|
||||||
saved state or track not found."
|
Returns NIL if no state or playlist found."
|
||||||
(let ((state (load-playback-state)))
|
(let ((state (load-playback-state)))
|
||||||
(if state
|
(when state
|
||||||
(let* ((saved-file (getf state :track-file))
|
(let* ((saved-file (getf state :track-file))
|
||||||
(pos (position saved-file file-list :test #'string=)))
|
(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
|
(if pos
|
||||||
(let ((remaining (nthcdr (1+ pos) file-list)))
|
(let ((remaining (nthcdr (1+ pos) file-list)))
|
||||||
(if remaining
|
(if remaining
|
||||||
(progn
|
(progn
|
||||||
(log:info "Resuming after track ~A (~A of ~A)"
|
(log:info "Resuming after track ~A (~A of ~A) from ~A"
|
||||||
(file-namestring saved-file) (1+ pos) (length file-list))
|
(file-namestring saved-file) (1+ pos) (length file-list)
|
||||||
remaining)
|
(file-namestring playlist-path))
|
||||||
;; Was the last track — start from beginning
|
(values remaining playlist-path))
|
||||||
(progn
|
(progn
|
||||||
(log:info "Last saved track was final in playlist, starting from beginning")
|
(log:info "Last saved track was final, restarting ~A"
|
||||||
file-list)))
|
(file-namestring playlist-path))
|
||||||
|
(values file-list playlist-path))))
|
||||||
(progn
|
(progn
|
||||||
(log:info "Saved track not found in current playlist, starting from beginning")
|
(log:info "Saved track not found, starting ~A from beginning"
|
||||||
file-list)))
|
(file-namestring playlist-path))
|
||||||
file-list)))
|
(values file-list playlist-path)))))))))
|
||||||
|
|
||||||
;;; ---- M3U Playlist Loading ----
|
;;; ---- M3U Playlist Loading ----
|
||||||
|
|
||||||
|
|
@ -228,6 +247,8 @@
|
||||||
(when *harmony-pipeline*
|
(when *harmony-pipeline*
|
||||||
(let ((file-list (m3u-to-file-list m3u-path)))
|
(let ((file-list (m3u-to-file-list m3u-path)))
|
||||||
(when file-list
|
(when file-list
|
||||||
|
;; Track which playlist is active for state persistence
|
||||||
|
(setf *current-playlist-path* (pathname m3u-path))
|
||||||
;; Clear any existing queue and load new files
|
;; Clear any existing queue and load new files
|
||||||
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
|
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
|
||||||
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
|
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
|
||||||
|
|
@ -237,8 +258,7 @@
|
||||||
;; Only skip if explicitly requested
|
;; Only skip if explicitly requested
|
||||||
(when skip
|
(when skip
|
||||||
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*))
|
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*))
|
||||||
(log:info "Loaded playlist ~A (~A tracks~A)" m3u-path (length file-list)
|
(log:info "Loaded playlist ~A (~A tracks)" (file-namestring m3u-path) (length file-list))
|
||||||
(if skip ", skipping to start" ""))
|
|
||||||
(length file-list)))))
|
(length file-list)))))
|
||||||
|
|
||||||
(defun harmony-skip-track ()
|
(defun harmony-skip-track ()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue