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
(progn
(start-harmony-streaming)
;; Load the current playlist and start playing (resume from saved position)
(let ((playlist-path (get-stream-queue-path)))
(when (probe-file playlist-path)
(let* ((file-list (m3u-to-file-list playlist-path))
(resumed-list (when file-list (resume-from-saved-state file-list))))
;; Load playlist and start playing (resume from saved position if available)
(multiple-value-bind (resumed-list playlist-path)
(resume-from-saved-state)
;; Fall back to stream-queue.m3u if no saved state
(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
(cl-streamer/harmony:play-list *harmony-pipeline* resumed-list
:crossfade-duration 3.0
:loop-queue t)
(format t "~A tracks loaded from stream-queue.m3u (~A remaining after resume)~%"
(length file-list) (length resumed-list))))))
(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.aac~%" *stream-base-url*))
(error (e)

View File

@ -216,14 +216,19 @@
;;; ---- 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)
"Read metadata (artist, title, album) from an audio file using taglib.
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
(handler-case
(let ((audio-file (audio-streams:open-audio-file (namestring file-path))))
(list :artist (or (abstract-tag:artist audio-file) nil)
:title (or (abstract-tag:title audio-file) nil)
:album (or (abstract-tag:album audio-file) nil)))
(list :artist (ensure-simple-string (abstract-tag:artist audio-file))
:title (ensure-simple-string (abstract-tag:title audio-file))
:album (ensure-simple-string (abstract-tag:album audio-file))))
(error (e)
(log:debug "Could not read tags from ~A: ~A" file-path e)
nil)))
@ -309,17 +314,31 @@
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
(sleep step-time))))
(defun next-entry (pipeline file-list-ref)
"Get the next entry to play: from queue first (scheduler priority), then file-list.
FILE-LIST-REF is a cons cell whose car is the remaining file list.
When the scheduler queues a new playlist, it replaces the remaining file-list
so the new playlist takes effect immediately.
Returns an entry or NIL if nothing to play."
(let ((queued (pipeline-pop-queue pipeline)))
(when queued
;; Scheduler queued new tracks — discard remaining file-list
(setf (car file-list-ref) nil))
(or queued (pop (car file-list-ref)))))
(defun drain-queue-into-remaining (pipeline remaining-ref current-list-ref)
"If the scheduler has queued tracks, drain them all into remaining-ref,
replacing any current remaining tracks. Also update current-list-ref
so loop-queue replays the scheduler's playlist, not the original.
Returns T if new tracks were loaded, NIL otherwise."
(let ((first (pipeline-pop-queue pipeline)))
(when first
(let ((all-queued (list first)))
;; Drain remaining queue entries
(loop for item = (pipeline-pop-queue pipeline)
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)
(fade-in 2.0)
@ -334,16 +353,19 @@
Scheduler-queued tracks take priority over the repeat cycle."
(bt:make-thread
(lambda ()
(handler-case
(let ((prev-voice nil)
(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)
for entry = (next-entry pipeline remaining-list)
for entry = (next-entry pipeline remaining-list current-list)
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)
(log:info "Playlist ended, repeating from start")
(setf (car remaining-list) (copy-list file-list)))
(log:info "Playlist ended, repeating from start (~A tracks)"
(length (car current-list)))
(setf (car remaining-list) (copy-list (car current-list))))
;; No entry: done
((null entry)
(return))
@ -357,9 +379,18 @@
(let* ((server (pipeline-harmony-server pipeline))
(harmony:*server* server))
(multiple-value-bind (voice display-title track-info)
(handler-case
(play-file pipeline path :title title
: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
;; If this isn't the first track, crossfade
(when (and prev-voice (> idx 0))
@ -415,7 +446,9 @@
(when prev-voice
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
(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"))
(declaim (inline float-to-s16))

View File

@ -24,8 +24,11 @@
;;; ---- 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 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."
(handler-case
(with-open-file (s *harmony-state-file*
@ -33,6 +36,8 @@
: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)
@ -48,29 +53,43 @@
(log:warn "Could not load playback state: ~A" e)
nil)))
(defun resume-from-saved-state (file-list)
"Given a playlist FILE-LIST, find the saved track and return the list
starting from the NEXT track after it. Returns the full list if no
saved state or track not found."
(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)))
(if state
(when state
(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
(let ((remaining (nthcdr (1+ pos) file-list)))
(if remaining
(progn
(log:info "Resuming after track ~A (~A of ~A)"
(file-namestring saved-file) (1+ pos) (length file-list))
remaining)
;; Was the last track — start from beginning
(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 in playlist, starting from beginning")
file-list)))
(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 in current playlist, starting from beginning")
file-list)))
file-list)))
(log:info "Saved track not found, starting ~A from beginning"
(file-namestring playlist-path))
(values file-list playlist-path)))))))))
;;; ---- M3U Playlist Loading ----
@ -228,6 +247,8 @@
(when *harmony-pipeline*
(let ((file-list (m3u-to-file-list m3u-path)))
(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
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
@ -237,8 +258,7 @@
;; Only skip if explicitly requested
(when skip
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*))
(log:info "Loaded playlist ~A (~A tracks~A)" m3u-path (length file-list)
(if skip ", skipping to start" ""))
(log:info "Loaded playlist ~A (~A tracks)" (file-namestring m3u-path) (length file-list))
(length file-list)))))
(defun harmony-skip-track ()