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:
Glenn Thompson 2026-03-05 13:25:15 +03:00
parent 16da880822
commit 9ae7546466
3 changed files with 132 additions and 73 deletions

View File

@ -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
(when resumed-list (let ((queue-path (get-stream-queue-path)))
(cl-streamer/harmony:play-list *harmony-pipeline* resumed-list (when (probe-file queue-path)
:crossfade-duration 3.0 (setf resumed-list (m3u-to-file-list queue-path))
:loop-queue t) (setf playlist-path queue-path)
(format t "~A tracks loaded from stream-queue.m3u (~A remaining after resume)~%" (setf *current-playlist-path* queue-path))))
(length file-list) (length resumed-list)))))) (when resumed-list
(cl-streamer/harmony:play-list *harmony-pipeline* resumed-list
:crossfade-duration 3.0
:loop-queue t)
(format t "~A tracks loaded from ~A~%"
(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)

View File

@ -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,21 +353,24 @@
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 ()
(let ((prev-voice nil) (handler-case
(idx 0) (let ((prev-voice nil)
(remaining-list (list (copy-list file-list)))) (idx 0)
(loop while (pipeline-running-p pipeline) (remaining-list (list (copy-list file-list)))
for entry = (next-entry pipeline remaining-list) (current-list (list (copy-list file-list))))
do (cond (loop while (pipeline-running-p pipeline)
;; No entry and loop mode: re-queue original playlist for entry = (next-entry pipeline remaining-list current-list)
((and (null entry) loop-queue) do (cond
(log:info "Playlist ended, repeating from start") ;; No entry and loop mode: re-queue current playlist
(setf (car remaining-list) (copy-list file-list))) ((and (null entry) loop-queue)
;; No entry: done (log:info "Playlist ended, repeating from start (~A tracks)"
((null entry) (length (car current-list)))
(return)) (setf (car remaining-list) (copy-list (car current-list))))
;; Play the entry ;; No entry: done
(t ((null entry)
(return))
;; Play the entry
(t
(multiple-value-bind (path title) (multiple-value-bind (path title)
(if (listp entry) (if (listp entry)
(values (getf entry :file) (getf entry :title)) (values (getf entry :file) (getf entry :title))
@ -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)
(play-file pipeline path :title title (handler-case
:on-end :disconnect (play-file pipeline path :title title
:update-metadata (null prev-voice)) :on-end :disconnect
: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))
@ -411,11 +442,13 @@
(error (e) (error (e)
(log:warn "Error playing ~A: ~A" path e) (log:warn "Error playing ~A: ~A" path e)
(sleep 1))))))) (sleep 1)))))))
;; Clean up last voice ;; Clean up last voice
(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))

View File

@ -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))
(if pos ;; Use saved playlist if it exists, otherwise fall back to current scheduled
(let ((remaining (nthcdr (1+ pos) file-list))) (playlist-path (or (and saved-playlist
(if remaining (probe-file (pathname saved-playlist)))
(progn (let ((scheduled (get-current-scheduled-playlist)))
(log:info "Resuming after track ~A (~A of ~A)" (when scheduled
(file-namestring saved-file) (1+ pos) (length file-list)) (let ((p (merge-pathnames scheduled (get-playlists-directory))))
remaining) (probe-file p))))))
;; Was the last track — start from beginning (file-list (when playlist-path
(progn (m3u-to-file-list playlist-path))))
(log:info "Last saved track was final in playlist, starting from beginning") (when file-list
file-list))) (setf *current-playlist-path* playlist-path)
(progn (let ((pos (when saved-file
(log:info "Saved track not found in current playlist, starting from beginning") (position saved-file file-list :test #'string=))))
file-list))) (if pos
file-list))) (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 ---- ;;; ---- 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 ()