diff --git a/asteroid.lisp b/asteroid.lisp index 9d5ce53..26dd3de 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -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)))) - (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)))))) + ;; 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 ~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) diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp index 9b76892..e87315d 100644 --- a/cl-streamer/harmony-backend.lisp +++ b/cl-streamer/harmony-backend.lisp @@ -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,21 +353,24 @@ Scheduler-queued tracks take priority over the repeat cycle." (bt:make-thread (lambda () - (let ((prev-voice nil) - (idx 0) - (remaining-list (list (copy-list file-list)))) - (loop while (pipeline-running-p pipeline) - for entry = (next-entry pipeline remaining-list) - do (cond - ;; No entry and loop mode: re-queue original playlist - ((and (null entry) loop-queue) - (log:info "Playlist ended, repeating from start") - (setf (car remaining-list) (copy-list file-list))) - ;; No entry: done - ((null entry) - (return)) - ;; Play the entry - (t + (handler-case + (let ((prev-voice nil) + (idx 0) + (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 current-list) + do (cond + ;; No entry and loop mode: re-queue current playlist + ((and (null entry) loop-queue) + (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)) + ;; Play the entry + (t (multiple-value-bind (path title) (if (listp entry) (values (getf entry :file) (getf entry :title)) @@ -357,9 +379,18 @@ (let* ((server (pipeline-harmony-server pipeline)) (harmony:*server* server)) (multiple-value-bind (voice display-title track-info) - (play-file pipeline path :title title - :on-end :disconnect - :update-metadata (null prev-voice)) + (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)) @@ -411,11 +442,13 @@ (error (e) (log:warn "Error playing ~A: ~A" path e) (sleep 1))))))) - ;; Clean up last voice - (when prev-voice - (let ((harmony:*server* (pipeline-harmony-server pipeline))) - (volume-ramp prev-voice 0.0 fade-out) - (harmony:stop prev-voice))))) + ;; Clean up last voice + (when prev-voice + (let ((harmony:*server* (pipeline-harmony-server pipeline))) + (volume-ramp prev-voice 0.0 fade-out) + (harmony:stop prev-voice)))) + (error (e) + (log:error "play-list thread crashed: ~A" e)))) :name "cl-streamer-playlist")) (declaim (inline float-to-s16)) diff --git a/stream-harmony.lisp b/stream-harmony.lisp index fd2997b..800e7fd 100644 --- a/stream-harmony.lisp +++ b/stream-harmony.lisp @@ -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 - (let* ((saved-file (getf state :track-file)) - (pos (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 - (progn - (log:info "Last saved track was final in playlist, starting from beginning") - file-list))) - (progn - (log:info "Saved track not found in current playlist, starting from beginning") - file-list))) - file-list))) + (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 ---- @@ -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 ()