cl-streamer integration fixes: CORS, reconnect, stream config

Server-side fixes (stream-server.lisp):
- Add CORS preflight (OPTIONS) request handler for browser crossorigin audio
- AAC clients start from current buffer position instead of burst to avoid
  ADTS frame alignment issues that caused browser decode errors
- Upgrade client stream error logging from debug to warn for diagnostics
- Add send-cors-preflight function with proper Access-Control headers

Frontend fixes (stream-player.lisp):
- Rewrite reconnect-stream to reuse existing audio element instead of
  creating a new one, preserving browser user gesture context and preventing
  NotAllowedError on autoplay after reconnect
- Unify stream config: both curated and shuffle channels use same mount
  points (asteroid.mp3/asteroid.aac) since cl-streamer has a single pipeline
- Remove non-existent /asteroid-shuffle.mp3 mount reference that caused
  404s and broken pipe cascade when switching to shuffle channel
- Map :low quality to same MP3 mount (asteroid-low.mp3 not yet available)

Note: Channel selector preserved for future multi-stream support.
Recently-played API works correctly; frontend rendering to investigate separately.
This commit is contained in:
Glenn Thompson 2026-03-03 23:17:01 +03:00
parent 77458467c4
commit fd1bc504a5
2 changed files with 75 additions and 107 deletions

View File

@ -122,7 +122,13 @@
:external-format :latin-1)))
(handler-case
(let* ((request-line (read-line stream))
(headers (read-http-headers stream)))
(headers (read-http-headers stream))
(method (first (split-sequence:split-sequence #\Space request-line))))
;; Handle CORS preflight
(when (string-equal method "OPTIONS")
(send-cors-preflight stream)
(ignore-errors (usocket:socket-close client-socket))
(return-from handle-client))
(multiple-value-bind (path wants-meta)
(parse-icy-request request-line headers)
(let ((mount (gethash path (server-mounts server))))
@ -178,8 +184,13 @@
(stream (client-stream client))
(chunk-size 4096)
(chunk (make-array chunk-size :element-type '(unsigned-byte 8))))
;; Start from burst position for fast playback
(setf (client-read-pos client) (buffer-burst-start buffer))
;; For MP3, burst recent data for fast playback start.
;; For AAC, start from current position — AAC requires ADTS frame alignment
;; and burst data from mid-stream causes browser decode errors.
(setf (client-read-pos client)
(if (string= (mount-content-type mount) "audio/aac")
(buffer-current-pos buffer)
(buffer-burst-start buffer)))
(loop while (client-active-p client)
do (multiple-value-bind (bytes-read new-pos)
(buffer-read-from buffer (client-read-pos client) chunk)
@ -195,7 +206,8 @@
(write-sequence chunk stream :end bytes-read))
(force-output stream))
(error (e)
(log:debug "Client stream error: ~A" e)
(log:warn "Client stream error on ~A: ~A"
(mount-path mount) e)
(setf (client-active-p client) nil)
(return)))))))))
@ -221,6 +233,16 @@
(incf (client-bytes-since-meta client) bytes-remaining)
(setf pos length)))))))
(defun send-cors-preflight (stream)
"Send a CORS preflight response for OPTIONS requests."
(format stream "HTTP/1.1 204 No Content~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Origin: *~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Methods: GET, OPTIONS~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Allow-Headers: Origin, Accept, Content-Type, Icy-MetaData, Range~C~C" #\Return #\Linefeed)
(format stream "Access-Control-Max-Age: 86400~C~C" #\Return #\Linefeed)
(format stream "~C~C" #\Return #\Linefeed)
(force-output stream))
(defun send-404 (stream path)
"Send a 404 response for unknown mount points."
(format stream "HTTP/1.1 404 Not Found~C~C" #\Return #\Linefeed)

View File

@ -149,9 +149,10 @@
;; ========================================
;; Get stream configuration for a given channel and quality
;; Curated channel has multiple quality options, shuffle has only one
;; With cl-streamer, both channels use the same stream mounts -
;; channel switching loads a different playlist server-side
(defun get-stream-config (stream-base-url channel quality)
(let ((curated-config (ps:create
(let ((config (ps:create
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
:type "audio/aac"
:format "AAC 96kbps Stereo"
@ -160,17 +161,11 @@
:type "audio/mpeg"
:format "MP3 128kbps Stereo"
:mount "asteroid.mp3")
:low (ps:create :url (+ stream-base-url "/asteroid-low.mp3")
:low (ps:create :url (+ stream-base-url "/asteroid.mp3")
:type "audio/mpeg"
:format "MP3 64kbps Stereo"
:mount "asteroid-low.mp3")))
(shuffle-config (ps:create :url (+ stream-base-url "/asteroid-shuffle.mp3")
:type "audio/mpeg"
:format "Shuffle MP3 96kbps"
:mount "asteroid-shuffle.mp3")))
(if (= channel "shuffle")
shuffle-config
(ps:getprop curated-config quality))))
:format "MP3 128kbps Stereo"
:mount "asteroid.mp3"))))
(ps:getprop config quality)))
;; Get current channel from selector or localStorage
(defun get-current-channel ()
@ -672,101 +667,52 @@
(defvar *reconnect-timeout* nil)
(defvar *is-reconnecting* false)
;; Reconnect stream - recreates audio element to fix wedged state
;; Reconnect stream - reuses existing audio element to preserve user gesture context
(defun reconnect-stream ()
(ps:chain console (log "Reconnecting stream..."))
(show-status "🔄 Reconnecting..." false)
(let* ((container (ps:chain document (query-selector ".persistent-player")))
(old-audio (ps:chain document (get-element-by-id "persistent-audio")))
(let* ((audio (ps:chain document (get-element-by-id "persistent-audio")))
(source (ps:chain document (get-element-by-id "audio-source")))
(stream-base-url (ps:@ (ps:chain document (get-element-by-id "stream-base-url")) value))
(stream-channel (get-current-channel))
(stream-quality (get-current-quality))
(config (get-stream-config stream-base-url stream-channel stream-quality)))
(unless (and container old-audio)
(unless audio
(show-status "❌ Could not reconnect - reload page" true)
(setf *is-reconnecting* false)
(return-from reconnect-stream nil))
;; Save current volume and muted state
(let ((saved-volume (ps:@ old-audio volume))
(saved-muted (ps:@ old-audio muted)))
(ps:chain console (log "Saving volume:" saved-volume "muted:" saved-muted))
(ps:chain console (log "Saving volume:" (ps:@ audio volume) "muted:" (ps:@ audio muted)))
;; Reset spectrum analyzer if it exists
(when (ps:@ window reset-spectrum-analyzer)
(ps:chain window (reset-spectrum-analyzer)))
;; Stop and remove old audio
(ps:chain old-audio (pause))
(setf (ps:@ old-audio src) "")
(ps:chain old-audio (load))
;; Create new audio element
(let ((new-audio (ps:chain document (create-element "audio"))))
(setf (ps:@ new-audio id) "persistent-audio")
(setf (ps:@ new-audio controls) true)
(setf (ps:@ new-audio preload) "metadata")
(setf (ps:@ new-audio cross-origin) "anonymous")
;; Restore volume and muted state
(setf (ps:@ new-audio volume) saved-volume)
(setf (ps:@ new-audio muted) saved-muted)
;; Create source
(let ((source (ps:chain document (create-element "source"))))
(setf (ps:@ source id) "audio-source")
(setf (ps:@ source src) (ps:@ config url))
(setf (ps:@ source type) (ps:@ config type))
(ps:chain new-audio (append-child source)))
;; Replace old audio with new
(ps:chain old-audio (replace-with new-audio))
;; Re-attach event listeners
(attach-audio-listeners new-audio)
;; Try to play - reset flag so error handler can catch failures
(setf *is-reconnecting* false)
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(ps:chain console (log "Reconnected successfully"))
(show-status "✓ Reconnected!" false)
;; Reinitialize spectrum analyzer
(when (ps:@ window init-spectrum-analyzer)
(set-timeout (lambda ()
(ps:chain window (init-spectrum-analyzer)))
500))
;; Also try in content frame
(set-timeout
(lambda ()
(ps:try
(let ((content-frame (ps:@ (ps:@ window parent) frames "content-frame")))
(when (and content-frame (ps:@ content-frame init-spectrum-analyzer))
(when (ps:@ content-frame reset-spectrum-analyzer)
(ps:chain content-frame (reset-spectrum-analyzer)))
(ps:chain content-frame (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized in content frame"))))
(:catch (e)
(ps:chain console (log "Could not reinit spectrum in content frame:" e)))))
600)))
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err))
;; Retry with exponential backoff
(incf *stream-error-count*)
(if (< *stream-error-count* 5)
(let ((delay (* 2000 *stream-error-count*)))
(show-status (+ "⚠️ Reconnect failed, retrying in " (/ delay 1000) "s...") true)
(setf *is-reconnecting* false)
(setf *reconnect-timeout*
(set-timeout (lambda () (reconnect-stream)) delay)))
;; Reload source on existing element (preserves user gesture context)
(ps:chain audio (pause))
(if source
;; Update existing source element
(progn
(setf (ps:@ source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time))))
(setf (ps:@ source type) (ps:@ config type)))
;; Create source if missing
(let ((new-source (ps:chain document (create-element "source"))))
(setf (ps:@ new-source id) "audio-source")
(setf (ps:@ new-source src) (+ (ps:@ config url) "?t=" (ps:chain (ps:new (*Date)) (get-time))))
(setf (ps:@ new-source type) (ps:@ config type))
(ps:chain audio (append-child new-source))))
;; Reload and play
(ps:chain audio (load))
(setf *is-reconnecting* false)
(show-status "❌ Could not reconnect. Click play to try again." true)))))))
300)))))
(set-timeout
(lambda ()
(ps:chain audio (play)
(catch (lambda (error)
(ps:chain console (log "Reconnect play failed:" error))))))
200)))
;; Simple reconnect for popout player (just reload and play)
(defun simple-reconnect (audio-element)