diff --git a/asteroid-radio.liq b/asteroid-radio.liq
deleted file mode 100755
index 35906de..0000000
--- a/asteroid-radio.liq
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/usr/bin/liquidsoap
-
-# Asteroid Radio - Simple streaming script
-# Streams music library continuously to Icecast2
-
-# Set log level for debugging
-settings.log.level := 4
-
-# Create playlist source - use single_track to test first
-# radio = single("/home/glenn/Projects/Code/asteroid/music/library/03-Driving.mp3")
-
-# Create playlist from directory (simpler approach)
-radio = playlist(mode="randomize", reload=3600, "/home/glenn/Projects/Code/asteroid/music/library/")
-
-# Add some processing
-radio = amplify(1.0, radio)
-
-# Make source safe with fallback but prefer the music
-radio = fallback(track_sensitive=false, [radio, sine(440.0)])
-
-# Output to Icecast2
-output.icecast(
- %mp3(bitrate=128),
- host="localhost",
- port=8000,
- password="H1tn31EhsyLrfRmo",
- mount="asteroid.mp3",
- name="Asteroid Radio",
- description="Music for Hackers - Streaming from the Asteroid",
- genre="Electronic/Alternative",
- url="http://localhost:8080/asteroid/",
- radio
-)
-
-print("🎵 Asteroid Radio streaming started!")
-print("Stream URL: http://localhost:8000/asteroid.mp3")
-print("Admin panel: http://localhost:8000/admin/")
diff --git a/asteroid.lisp b/asteroid.lisp
index 26dd3de..1304505 100644
--- a/asteroid.lisp
+++ b/asteroid.lisp
@@ -443,12 +443,7 @@
(let ((count (load-queue-from-m3u-file))
(channel-name (get-curated-channel-name)))
;; Skip/switch to new playlist
- (if *harmony-pipeline*
- (harmony-load-playlist playlist-path)
- (handler-case
- (liquidsoap-command "stream-queue_m3u.skip")
- (error (e)
- (format *error-output* "Warning: Could not skip track: ~a~%" e))))
+ (harmony-load-playlist playlist-path)
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded playlist: ~a" name))
("count" . ,count)
@@ -480,7 +475,7 @@
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
(define-api asteroid/stream/playlists/clear () ()
- "Clear stream-queue.m3u (Liquidsoap will fall back to random)"
+ "Clear stream-queue.m3u"
(require-role :admin)
(with-error-handling
(let ((stream-queue-path (get-stream-queue-path)))
@@ -492,7 +487,7 @@
;; Clear in-memory queue
(setf *stream-queue* '())
(api-output `(("status" . "success")
- ("message" . "Stream queue cleared - Liquidsoap will use random playback"))))))
+ ("message" . "Stream queue cleared"))))))
(define-api asteroid/stream/playlists/current () ()
"Get current stream-queue.m3u contents with track info"
@@ -522,135 +517,46 @@
("path" . ,docker-path)))))
paths)))))))
-;;; Liquidsoap Control APIs
-;;; Control Liquidsoap via telnet interface on port 1234
+;;; Stream Control APIs
-(defun liquidsoap-command (command)
- "Send a command to Liquidsoap via telnet and return the response"
- (handler-case
- (let ((result (uiop:run-program
- (format nil "echo '~a' | nc -q1 127.0.0.1 1234" command)
- :output :string
- :error-output :string
- :ignore-error-status t)))
- ;; Remove the trailing "END" line
- (let ((lines (cl-ppcre:split "\\n" result)))
- (string-trim '(#\Space #\Newline #\Return)
- (format nil "~{~a~^~%~}"
- (remove-if (lambda (l) (string= (string-trim '(#\Space #\Return) l) "END"))
- lines)))))
- (error (e)
- (format nil "Error: ~a" e))))
-
-(defun parse-liquidsoap-metadata (raw-metadata)
- "Parse Liquidsoap metadata string and extract current track info"
- (when (and raw-metadata (> (length raw-metadata) 0))
- ;; The metadata contains multiple tracks, separated by --- N ---
- ;; --- 1 --- is the CURRENT track (most recent), at the end of the output
- ;; Split by --- N --- pattern and get the last section
- (let* ((sections (cl-ppcre:split "---\\s*\\d+\\s*---" raw-metadata))
- (current-section (car (last sections))))
- (when current-section
- (let ((artist (cl-ppcre:register-groups-bind (val)
- ("artist=\"([^\"]+)\"" current-section) val))
- (title (cl-ppcre:register-groups-bind (val)
- ("title=\"([^\"]+)\"" current-section) val))
- (album (cl-ppcre:register-groups-bind (val)
- ("album=\"([^\"]+)\"" current-section) val)))
- (if (or artist title)
- (format nil "~@[~a~]~@[ - ~a~]~@[ (~a)~]"
- artist title album)
- "Unknown"))))))
-
-(defun format-remaining-time (seconds-str)
- "Format remaining seconds as MM:SS"
- (handler-case
- (let ((seconds (parse-integer (cl-ppcre:regex-replace "\\..*" seconds-str ""))))
- (format nil "~d:~2,'0d" (floor seconds 60) (mod seconds 60)))
- (error () seconds-str)))
-
-(define-api asteroid/liquidsoap/status () ()
- "Get stream status - uses Harmony pipeline when available, falls back to Liquidsoap"
+(define-api asteroid/stream/status () ()
+ "Get stream status from Harmony pipeline."
(require-role :admin)
(with-error-handling
- (if *harmony-pipeline*
- (let ((status (harmony-get-status)))
- (api-output `(("status" . "success")
- ("backend" . "harmony")
- ("uptime" . "n/a")
- ("metadata" . ,(getf status :current-track))
- ("remaining" . "n/a")
- ("listeners" . ,(getf status :listeners))
- ("queue_length" . ,(getf status :queue-length)))))
- (let ((uptime (liquidsoap-command "uptime"))
- (metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
- (remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
- (api-output `(("status" . "success")
- ("backend" . "liquidsoap")
- ("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
- ("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
- ("remaining" . ,(format-remaining-time
- (string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))))
-
-(define-api asteroid/liquidsoap/skip () ()
- "Skip the current track"
- (require-role :admin)
- (with-error-handling
- (if *harmony-pipeline*
- (progn
- (harmony-skip-track)
- (api-output `(("status" . "success")
- ("message" . "Track skipped (Harmony)"))))
- (let ((result (liquidsoap-command "stream-queue_m3u.skip")))
- (api-output `(("status" . "success")
- ("message" . "Track skipped")
- ("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
-
-(define-api asteroid/liquidsoap/reload () ()
- "Force playlist reload"
- (require-role :admin)
- (with-error-handling
- (if *harmony-pipeline*
- (let* ((playlist-path (get-stream-queue-path))
- (count (harmony-load-playlist playlist-path)))
- (api-output `(("status" . "success")
- ("message" . ,(format nil "Playlist reloaded (~A tracks via Harmony)" count)))))
- (let ((result (liquidsoap-command "stream-queue_m3u.reload")))
- (api-output `(("status" . "success")
- ("message" . "Playlist reloaded")
- ("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
-
-(define-api asteroid/liquidsoap/restart () ()
- "Restart the streaming backend"
- (require-role :admin)
- (with-error-handling
- (if *harmony-pipeline*
- (progn
- (stop-harmony-streaming)
- (start-harmony-streaming)
- (api-output `(("status" . "success")
- ("message" . "Harmony pipeline restarted"))))
- (let ((result (uiop:run-program
- "docker restart asteroid-liquidsoap"
- :output :string
- :error-output :string
- :ignore-error-status t)))
- (api-output `(("status" . "success")
- ("message" . "Liquidsoap container restarting")
- ("result" . ,result)))))))
-
-(define-api asteroid/icecast/restart () ()
- "Restart the Icecast Docker container"
- (require-role :admin)
- (with-error-handling
- (let ((result (uiop:run-program
- "docker restart asteroid-icecast"
- :output :string
- :error-output :string
- :ignore-error-status t)))
+ (let ((status (harmony-get-status)))
(api-output `(("status" . "success")
- ("message" . "Icecast container restarting")
- ("result" . ,result))))))
+ ("backend" . "harmony")
+ ("uptime" . "n/a")
+ ("metadata" . ,(getf status :current-track))
+ ("remaining" . "n/a")
+ ("listeners" . ,(getf status :listeners))
+ ("queue_length" . ,(getf status :queue-length)))))))
+
+(define-api asteroid/stream/skip () ()
+ "Skip the current track."
+ (require-role :admin)
+ (with-error-handling
+ (harmony-skip-track)
+ (api-output `(("status" . "success")
+ ("message" . "Track skipped")))))
+
+(define-api asteroid/stream/reload () ()
+ "Force playlist reload."
+ (require-role :admin)
+ (with-error-handling
+ (let* ((playlist-path (get-stream-queue-path))
+ (count (harmony-load-playlist playlist-path)))
+ (api-output `(("status" . "success")
+ ("message" . ,(format nil "Playlist reloaded (~A tracks)" count)))))))
+
+(define-api asteroid/stream/restart () ()
+ "Restart the streaming pipeline."
+ (require-role :admin)
+ (with-error-handling
+ (stop-harmony-streaming)
+ (start-harmony-streaming)
+ (api-output `(("status" . "success")
+ ("message" . "Streaming pipeline restarted")))))
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
@@ -1009,32 +915,12 @@
(asdf:system-source-directory :asteroid))))))
;; Status check functions
-(defun check-icecast-status ()
- "Check if streaming backend is running.
- Uses Harmony pipeline status when available, falls back to Icecast HTTP check."
- (if *harmony-pipeline*
+(defun check-stream-status ()
+ "Check if the Harmony streaming pipeline is running."
+ (if (and *harmony-pipeline*
+ (cl-streamer/harmony:pipeline-running-p *harmony-pipeline*))
"🟢 Running (cl-streamer)"
- (handler-case
- (let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
- :want-stream nil
- :connection-timeout 2)))
- (if response "🟢 Running" "🔴 Not Running"))
- (error () "🔴 Not Running"))))
-
-(defun check-liquidsoap-status ()
- "Check if Liquidsoap is running via Docker.
- Returns N/A when using cl-streamer."
- (if *harmony-pipeline*
- "⚪ N/A (using cl-streamer)"
- (handler-case
- (let* ((output (with-output-to-string (stream)
- (uiop:run-program '("docker" "ps" "--filter" "name=liquidsoap" "--format" "{{.Status}}")
- :output stream
- :error-output nil
- :ignore-error-status t)))
- (running-p (search "Up" output)))
- (if running-p "🟢 Running" "🔴 Not Running"))
- (error () "🔴 Not Running"))))
+ "🔴 Not Running"))
;; Admin page (requires authentication)
(define-page admin #@"/admin" ()
@@ -1051,8 +937,7 @@
:database-status (handler-case
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
(error () "🔴 No Database Backend"))
- :liquidsoap-status (check-liquidsoap-status)
- :icecast-status (check-icecast-status)
+ :stream-status (check-stream-status)
:track-count (format nil "~d" track-count)
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
:stream-base-url *stream-base-url*
@@ -1382,48 +1267,17 @@
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("stream-status" . "live"))))
-;; Live stream status
+;; Live stream status (kept as asteroid/icecast-status for frontend API compatibility)
(define-api-with-limit asteroid/icecast-status () ()
- "Get live stream status. Uses Harmony pipeline when available, falls back to Icecast."
+ "Get live stream status from cl-streamer pipeline."
(with-error-handling
- (if *harmony-pipeline*
- ;; Return status from cl-streamer directly
- (let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
- (title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
- (listeners (or (cl-streamer:get-listener-count) 0)))
- (api-output
- `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
- ("title" . ,title)
- ("listeners" . ,listeners))))))))
- ;; Fallback: poll Icecast XML
- (let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
- (response (drakma:http-request icecast-url
- :want-stream nil
- :basic-authorization '("admin" "asteroid_admin_2024"))))
- (if response
- (let ((xml-string (if (stringp response)
- response
- (babel:octets-to-string response :encoding :utf-8))))
- (multiple-value-bind (match-start match-end)
- (cl-ppcre:scan "" xml-string)
- (declare (ignore match-end))
- (if match-start
- (let* ((source-section (subseq xml-string match-start
- (or (cl-ppcre:scan " " xml-string :start match-start)
- (length xml-string))))
- (titlep (cl-ppcre:all-matches "
" source-section))
- (listenersp (cl-ppcre:all-matches "" source-section))
- (title (if titlep (cl-ppcre:regex-replace-all ".*(.*?) .*" source-section "\\1") "Unknown"))
- (listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?) .*" source-section "\\1") "0")))
- (api-output
- `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
- ("title" . ,title)
- ("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
- (api-output
- `(("icestats" . (("source" . nil))))))))
- (api-output
- `(("error" . "Could not connect to Icecast server"))
- :status 503))))))
+ (let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
+ (title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
+ (listeners (or (cl-streamer:get-listener-count) 0)))
+ (api-output
+ `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
+ ("title" . ,title)
+ ("listeners" . ,listeners))))))))))
;;; Listener Statistics API Endpoints
@@ -1567,7 +1421,7 @@
;; TODO: Add auto-scan on startup once database timing issues are resolved
;; For now, use the "Scan Library" button in the admin interface
- ;; Start cl-streamer audio pipeline (replaces Icecast + Liquidsoap)
+ ;; Start cl-streamer audio pipeline
(format t "Starting cl-streamer audio pipeline...~%")
(handler-case
(progn
diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp
index 4ee5831..161e1a5 100644
--- a/cl-streamer/harmony-backend.lisp
+++ b/cl-streamer/harmony-backend.lisp
@@ -14,10 +14,13 @@
;; Track state & control
#:pipeline-current-track
#:pipeline-on-track-change
+ #:pipeline-running-p
#:pipeline-skip
#:pipeline-queue-files
#:pipeline-get-queue
#:pipeline-clear-queue
+ #:pipeline-pending-playlist-path
+ #:pipeline-on-playlist-change
;; Metadata helpers
#:read-audio-metadata
#:format-display-title))
@@ -117,7 +120,12 @@
(queue-lock :initform (bt:make-lock "pipeline-queue-lock")
:reader pipeline-queue-lock)
(skip-flag :initform nil :accessor pipeline-skip-flag
- :documentation "Set to T to skip the current track")))
+ :documentation "Set to T to skip the current track")
+ (pending-playlist-path :initform nil :accessor pipeline-pending-playlist-path
+ :documentation "Playlist path queued by scheduler, applied when tracks start playing")
+ (on-playlist-change :initarg :on-playlist-change :initform nil
+ :accessor pipeline-on-playlist-change
+ :documentation "Callback (lambda (pipeline playlist-path)) called when scheduler playlist starts")))
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
(sample-rate 44100) (channels 2))
@@ -218,9 +226,11 @@
(defun ensure-simple-string (s)
"Coerce S to a simple-string if it's a string, or return NIL.
- Uses coerce to guarantee SIMPLE-STRING type for downstream consumers."
+ Coerce first to guarantee simple-string before any string operations,
+ since SBCL's string-trim may require simple-string input."
(when (stringp s)
- (coerce (string-trim '(#\Space #\Nul) s) 'simple-string)))
+ (let ((simple (coerce s 'simple-string)))
+ (string-trim '(#\Space #\Nul) simple))))
(defun safe-tag (fn audio-file)
"Safely read a tag field, coercing to simple-string. Returns NIL on any error."
@@ -337,6 +347,16 @@
;; Replace remaining list and update current for loop-queue
(setf (car remaining-ref) all-queued)
(setf (car current-list-ref) (copy-list all-queued))
+ ;; Fire playlist-change callback so app layer updates metadata
+ (when (pipeline-on-playlist-change pipeline)
+ (let ((playlist-path (pipeline-pending-playlist-path pipeline)))
+ (when playlist-path
+ (handler-case
+ (funcall (pipeline-on-playlist-change pipeline)
+ pipeline playlist-path)
+ (error (e)
+ (log:warn "Playlist change callback error: ~A" e)))
+ (setf (pipeline-pending-playlist-path pipeline) nil))))
t))))
(defun next-entry (pipeline remaining-ref current-list-ref)
diff --git a/conditions.lisp b/conditions.lisp
index 7c1f38e..1f421a3 100644
--- a/conditions.lisp
+++ b/conditions.lisp
@@ -87,7 +87,7 @@
:initarg :stream-type
:reader error-stream-type
:initform nil
- :documentation "Type of stream (e.g., 'icecast', 'liquidsoap')"))
+ :documentation "Type of stream (e.g., 'harmony', 'cl-streamer')"))
(:documentation "Signaled when stream operations fail")
(:report (lambda (condition stream)
(format stream "Stream Error~@[ (~a)~]: ~a"
diff --git a/docker/Dockerfile.icecast b/docker/Dockerfile.icecast
deleted file mode 100644
index 14aa89d..0000000
--- a/docker/Dockerfile.icecast
+++ /dev/null
@@ -1,11 +0,0 @@
-FROM infiniteproject/icecast:latest
-
-# Copy entrypoint script
-COPY icecast-entrypoint.sh /usr/local/bin/icecast-entrypoint.sh
-RUN chmod +x /usr/local/bin/icecast-entrypoint.sh
-
-# Copy base config and YP snippet
-COPY icecast-base.xml /etc/icecast-base.xml
-COPY icecast-yp-snippet.xml /etc/icecast-yp-snippet.xml
-
-ENTRYPOINT ["/usr/local/bin/icecast-entrypoint.sh"]
diff --git a/docker/Dockerfile.liquidsoap b/docker/Dockerfile.liquidsoap
deleted file mode 100644
index 224ac7a..0000000
--- a/docker/Dockerfile.liquidsoap
+++ /dev/null
@@ -1,28 +0,0 @@
-# Use official Liquidsoap Docker image from Savonet team
-FROM savonet/liquidsoap:792d8bf
-
-# Switch to root for setup
-USER root
-
-# Create app directory and set permissions
-RUN mkdir -p /app/music /app/config && \
- chown -R liquidsoap:liquidsoap /app
-
-# Copy Liquidsoap script
-COPY asteroid-radio-docker.liq /app/asteroid-radio.liq
-
-# Make script executable and set ownership
-RUN chmod +x /app/asteroid-radio.liq && \
- chown liquidsoap:liquidsoap /app/asteroid-radio.liq
-
-# Switch to liquidsoap user for security
-USER liquidsoap
-
-# Set working directory
-WORKDIR /app
-
-# Expose port for potential HTTP interface
-EXPOSE 8001
-
-# Run Liquidsoap
-CMD ["liquidsoap", "/app/asteroid-radio.liq"]
diff --git a/docker/asteroid-radio-docker.liq b/docker/asteroid-radio-docker.liq
deleted file mode 100644
index 61ad2d7..0000000
--- a/docker/asteroid-radio-docker.liq
+++ /dev/null
@@ -1,176 +0,0 @@
-#!/usr/bin/liquidsoap
-
-# Asteroid Radio - Docker streaming script
-# Streams music library continuously to Icecast2 running in Docker
-
-# Allow running as root in Docker
-set("init.allow_root", true)
-
-# Set log level (4 = warning, suppresses info messages including telnet noise)
-log.level.set(4)
-
-# Audio buffering settings to prevent choppiness
-settings.frame.audio.samplerate.set(44100)
-settings.frame.audio.channels.set(2)
-# Use "fast" resampler instead of "best" to reduce CPU load on 96kHz files
-settings.audio.converter.samplerate.libsamplerate.quality.set("fast")
-
-# Prefer native decoders over FFmpeg for better performance
-settings.decoder.priorities.flac := 10
-settings.decoder.priorities.mad := 10
-settings.decoder.priorities.ffmpeg := 1
-
-# Enable telnet server for remote control
-settings.server.telnet.set(true)
-settings.server.telnet.port.set(1234)
-settings.server.telnet.bind_addr.set("0.0.0.0")
-
-# Station URL for YP directory listings (defaults to localhost for dev)
-station_url = environment.get("STATION_URL") ?? "http://localhost:8080/asteroid/"
-
-# =============================================================================
-# CURATED STREAM (Low Orbit) - Sequential playlist
-# =============================================================================
-
-# Create playlist source from generated M3U file
-# This file is managed by Asteroid's stream control system
-# Falls back to directory scan if playlist file doesn't exist
-radio = playlist(
- mode="normal", # Normal mode: play sequentially without initial randomization
- reload=300, # Check for playlist updates every 5 minutes
- reload_mode="watch", # Watch file for changes (more efficient than polling)
- "/app/stream-queue.m3u"
-)
-
-# Fallback to directory scan if playlist file is empty/missing
-radio_fallback = playlist.safe(
- mode="randomize",
- reload=3600,
- "/app/music/"
-)
-
-# Use main playlist, fall back to directory scan
-radio = fallback(track_sensitive=false, [radio, radio_fallback])
-
-# Simple crossfade for smooth transitions
-radio = crossfade(
- duration=3.0, # 3 second crossfade
- fade_in=2.0, # 2 second fade in
- fade_out=2.0, # 2 second fade out
- radio
-)
-
-# Add buffer after crossfade to handle high sample rate files (96kHz -> 44.1kHz resampling)
-radio = buffer(buffer=5.0, max=10.0, radio)
-
-# Create a fallback with emergency content
-emergency = sine(440.0)
-emergency = amplify(0.1, emergency)
-
-# Make source safe with fallback
-radio = fallback(track_sensitive=false, [radio, emergency])
-
-# Add metadata
-radio = map_metadata(fun(m) ->
- [("title", m["title"] ?? "Unknown Track"),
- ("artist", m["artist"] ?? "Unknown Artist"),
- ("album", m["album"] ?? "Unknown Album")], radio)
-
-# Output to Icecast2 (using container hostname)
-output.icecast(
- %mp3(bitrate=128),
- host="icecast", # Docker service name
- port=8000,
- password="H1tn31EhsyLrfRmo",
- mount="asteroid.mp3",
- name="Asteroid Radio",
- description="Music for Hackers - Streaming from the Asteroid",
- genre="Electronic/Alternative",
- url=station_url,
- public=true,
- radio
-)
-
-# AAC High Quality Stream (96kbps - better quality than 128kbps MP3)
-output.icecast(
- %fdkaac(bitrate=96),
- host="icecast",
- port=8000,
- password="H1tn31EhsyLrfRmo",
- mount="asteroid.aac",
- name="Asteroid Radio (AAC)",
- description="Music for Hackers - High efficiency AAC stream",
- genre="Electronic/Alternative",
- url=station_url,
- public=true,
- radio
-)
-
-# Low Quality MP3 Stream (for compatibility)
-output.icecast(
- %mp3(bitrate=64),
- host="icecast",
- port=8000,
- password="H1tn31EhsyLrfRmo",
- mount="asteroid-low.mp3",
- name="Asteroid Radio (Low Quality)",
- description="Music for Hackers - Low bandwidth stream",
- genre="Electronic/Alternative",
- url=station_url,
- public=true,
- radio
-)
-
-# =============================================================================
-# SHUFFLE STREAM (Deep Space) - Random from full library
-# =============================================================================
-
-# Create shuffle source from full music library
-shuffle_radio = playlist(
- mode="randomize", # Random mode: shuffle tracks
- reload=3600, # Reload playlist hourly
- "/app/music/"
-)
-
-# Apply crossfade for smooth transitions
-shuffle_radio = crossfade(
- duration=3.0,
- fade_in=2.0,
- fade_out=2.0,
- shuffle_radio
-)
-
-# Add buffer to handle high sample rate files
-shuffle_radio = buffer(buffer=5.0, max=10.0, shuffle_radio)
-
-# Make source safe with emergency fallback
-shuffle_radio = fallback(track_sensitive=false, [shuffle_radio, emergency])
-
-# Add metadata
-shuffle_radio = map_metadata(fun(m) ->
- [("title", m["title"] ?? "Unknown Track"),
- ("artist", m["artist"] ?? "Unknown Artist"),
- ("album", m["album"] ?? "Unknown Album")], shuffle_radio)
-
-# Shuffle Stream - Medium Quality MP3 (96kbps)
-output.icecast(
- %mp3(bitrate=96),
- host="icecast",
- port=8000,
- password="H1tn31EhsyLrfRmo",
- mount="asteroid-shuffle.mp3",
- name="Asteroid Radio (Shuffle)",
- description="Music for Hackers - Random shuffle from the library",
- genre="Electronic/Alternative",
- url=station_url,
- public=true,
- shuffle_radio
-)
-
-print("🎵 Asteroid Radio Docker streaming started!")
-print("High Quality MP3: http://localhost:8000/asteroid.mp3")
-print("High Quality AAC: http://localhost:8000/asteroid.aac")
-print("Low Quality MP3: http://localhost:8000/asteroid-low.mp3")
-print("Shuffle Stream: http://localhost:8000/asteroid-shuffle.mp3")
-print("Icecast Admin: http://localhost:8000/admin/")
-print("Telnet control: telnet localhost 1234")
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index b4b80f5..1b17bfa 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,40 +1,4 @@
services:
- icecast:
- build:
- context: .
- dockerfile: Dockerfile.icecast
- container_name: asteroid-icecast
- ports:
- - "${ICECAST_BIND:-127.0.0.1}:8000:8000"
- environment:
- - ICECAST_SOURCE_PASSWORD=H1tn31EhsyLrfRmo
- - ICECAST_ADMIN_PASSWORD=asteroid_admin_2024
- - ICECAST_RELAY_PASSWORD=asteroid_relay_2024
- - ICECAST_ENABLE_YP=${ICECAST_ENABLE_YP:-false}
- - ICECAST_HOSTNAME=${ICECAST_HOSTNAME:-localhost}
- restart: unless-stopped
- networks:
- - asteroid-network
-
- liquidsoap:
- build:
- context: .
- dockerfile: Dockerfile.liquidsoap
- container_name: asteroid-liquidsoap
- ports:
- - "127.0.0.1:1234:1234"
- depends_on:
- - icecast
- environment:
- - STATION_URL=${STATION_URL:-http://localhost:8080/asteroid/}
- volumes:
- - ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
- - ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
- - ${QUEUE_PLAYLIST:-../playlists/stream-queue.m3u}:/app/stream-queue.m3u:ro
- restart: unless-stopped
- networks:
- - asteroid-network
-
postgres:
image: postgres:17-alpine
container_name: asteroid-postgres
diff --git a/docker/docker-compose.yml.remote-backup b/docker/docker-compose.yml.remote-backup
deleted file mode 100644
index 68c69e6..0000000
--- a/docker/docker-compose.yml.remote-backup
+++ /dev/null
@@ -1,33 +0,0 @@
-services:
- icecast:
- image: infiniteproject/icecast:latest
- container_name: asteroid-icecast
- ports:
- - "8000:8000"
- volumes:
- - ./icecast.xml:/etc/icecast2/icecast.xml:ro
- environment:
- - ICECAST_SOURCE_PASSWORD=H1tn31EhsyLrfRmo
- - ICECAST_ADMIN_PASSWORD=asteroid_admin_2024
- - ICECAST_RELAY_PASSWORD=asteroid_relay_2024
- restart: unless-stopped
- networks:
- - asteroid-network
-
- liquidsoap:
- build:
- context: .
- dockerfile: Dockerfile.liquidsoap
- container_name: asteroid-liquidsoap
- depends_on:
- - icecast
- volumes:
- - /mnt/remote-music/Music:/app/music:ro
- - ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
- restart: unless-stopped
- networks:
- - asteroid-network
-
-networks:
- asteroid-network:
- driver: bridge
diff --git a/docker/icecast-base.xml b/docker/icecast-base.xml
deleted file mode 100644
index 1ec1f94..0000000
--- a/docker/icecast-base.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
- Asteroid Radio
- admin@asteroid.radio
-
-
- 100
- 5
- 524288
- 30
- 15
- 10
- 1
- 65535
-
-
-
- H1tn31EhsyLrfRmo
- asteroid_relay_2024
- admin
- asteroid_admin_2024
-
-
- localhost
-
-
- 8000
-
-
-
- 1
-
-
- /usr/share/icecast2
- /var/log/icecast
- /usr/share/icecast2/web
- /usr/share/icecast2/admin
-
-
-
-
- access.log
- error.log
- 3
- 10000
-
-
-
- 0
-
- icecast
- icecast
-
-
-
-
-
-
-
-
-
-
diff --git a/docker/icecast-entrypoint.sh b/docker/icecast-entrypoint.sh
deleted file mode 100644
index b001030..0000000
--- a/docker/icecast-entrypoint.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/bin/sh
-
-# Generate icecast.xml from base config
-# - Substitute hostname (defaults to localhost for dev)
-# - If ICECAST_ENABLE_YP=true, insert YP directory blocks
-
-cp /etc/icecast-base.xml /etc/icecast.xml
-
-# Set hostname (defaults to localhost if not specified)
-ICECAST_HOSTNAME=${ICECAST_HOSTNAME:-localhost}
-echo "Icecast hostname: $ICECAST_HOSTNAME"
-sed -i "s|localhost |$ICECAST_HOSTNAME |" /etc/icecast.xml
-
-if [ "$ICECAST_ENABLE_YP" = "true" ]; then
- echo "YP directory publishing ENABLED"
- # Insert YP config before closing tag
- # Use sed with a temp file to handle multi-line insertion
- head -n -1 /etc/icecast.xml > /tmp/icecast-temp.xml
- cat /etc/icecast-yp-snippet.xml >> /tmp/icecast-temp.xml
- echo "" >> /tmp/icecast-temp.xml
- mv /tmp/icecast-temp.xml /etc/icecast.xml
-else
- echo "YP directory publishing DISABLED (dev mode)"
-fi
-
-# Start icecast
-exec icecast -c /etc/icecast.xml
diff --git a/docker/icecast-yp-snippet.xml b/docker/icecast-yp-snippet.xml
deleted file mode 100644
index 617cdbc..0000000
--- a/docker/icecast-yp-snippet.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- 15
- http://icecast-yp.internet-radio.com
-
-
- 15
- http://dir.xiph.org/cgi-bin/yp-cgi
-
diff --git a/docker/icecast.xml b/docker/icecast.xml
deleted file mode 100644
index e281482..0000000
--- a/docker/icecast.xml
+++ /dev/null
@@ -1,71 +0,0 @@
-
- Asteroid Radio
- admin@asteroid.radio
-
-
- 100
- 5
- 524288
- 30
- 15
- 10
- 1
- 65535
-
-
-
- H1tn31EhsyLrfRmo
- asteroid_relay_2024
- admin
- asteroid_admin_2024
-
-
- localhost
-
-
-
- 15
- http://icecast-yp.internet-radio.com
-
-
- 15
- http://dir.xiph.org/cgi-bin/yp-cgi
-
-
-
- 8000
-
-
-
- 1
-
-
- /usr/share/icecast2
- /var/log/icecast
- /usr/share/icecast2/web
- /usr/share/icecast2/admin
-
-
-
-
- access.log
- error.log
- 3
- 10000
-
-
-
- 0
-
- icecast
- icecast
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend-partials.lisp b/frontend-partials.lisp
index 92fd3af..07d4803 100644
--- a/frontend-partials.lisp
+++ b/frontend-partials.lisp
@@ -2,7 +2,7 @@
(defun find-track-by-title (title)
"Find a track in the database by its title. Returns track ID or nil.
- Handles 'Artist - Title' format from Icecast metadata."
+ Handles 'Artist - Title' format from stream metadata."
(when (and title (not (string= title "Unknown")))
(handler-case
(with-db
@@ -35,74 +35,15 @@
(declare (ignore e))
nil))))
-(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
- "Fetch now-playing information from Icecast server.
-
- ICECAST-BASE-URL - Base URL of the Icecast server (e.g. http://localhost:8000)
- MOUNT - Mount point to fetch metadata from (default: asteroid.mp3)
-
- Returns a plist with :listenurl, :title, and :listeners, or NIL on error."
- (let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url))
- (response (drakma:http-request icecast-url
- :want-stream nil
- :basic-authorization '("admin" "asteroid_admin_2024"))))
- (when response
- (let ((xml-string (if (stringp response)
- response
- (babel:octets-to-string response :encoding :utf-8))))
- ;; Extract total listener count from root tag (sums all mount points)
- ;; Extract title from specified mount point
- (let* ((total-listeners (multiple-value-bind (match groups)
- (cl-ppcre:scan-to-strings "(\\d+) " xml-string)
- (if (and match groups)
- (parse-integer (aref groups 0) :junk-allowed t)
- 0)))
- ;; Escape dots in mount name for regex
- (mount-pattern (format nil ""
- (cl-ppcre:regex-replace-all "\\." mount "\\\\.")))
- (mount-start (cl-ppcre:scan mount-pattern xml-string))
- (title (if mount-start
- (let* ((source-section (subseq xml-string mount-start
- (or (cl-ppcre:scan " " xml-string :start mount-start)
- (length xml-string)))))
- (multiple-value-bind (match groups)
- (cl-ppcre:scan-to-strings "(.*?) " source-section)
- (if (and match groups)
- (plump:decode-entities (aref groups 0))
- "Unknown")))
- "Unknown")))
-
- ;; Track recently played if title changed
- ;; Use appropriate last-known-track and list based on stream type
- (let* ((is-shuffle (string= mount "asteroid-shuffle.mp3"))
- (last-known (if is-shuffle *last-known-track-shuffle* *last-known-track-curated*))
- (stream-type (if is-shuffle :shuffle :curated)))
- (when (and title
- (not (string= title "Unknown"))
- (not (equal title last-known)))
- (if is-shuffle
- (setf *last-known-track-shuffle* title)
- (setf *last-known-track-curated* title))
- (add-recently-played (list :title title
- :timestamp (get-universal-time))
- stream-type)))
-
- `((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
- (:title . ,title)
- (:listeners . ,total-listeners)
- (:track-id . ,(find-track-by-title title))
- (:favorite-count . ,(or (get-track-favorite-count title) 1))))))))
-
(defun get-now-playing-stats (&optional (mount "asteroid.mp3"))
- "Get now-playing stats from Harmony pipeline, falling back to Icecast.
+ "Get now-playing stats from the Harmony pipeline.
Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count."
- (or (harmony-now-playing mount)
- (icecast-now-playing *stream-base-url* mount)))
+ (harmony-now-playing mount))
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1)
"Get Partial HTML with live now-playing status.
Optional MOUNT parameter specifies which stream to get metadata from.
- Uses Harmony pipeline when available, falls back to Icecast."
+ Returns partial HTML with current track info."
(with-error-handling
(let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (get-now-playing-stats mount-name)))
diff --git a/listener-stats.lisp b/listener-stats.lisp
index 64747cc..b80feea 100644
--- a/listener-stats.lisp
+++ b/listener-stats.lisp
@@ -1,5 +1,5 @@
;;;; listener-stats.lisp - Listener Statistics Collection Service
-;;;; Polls Icecast for listener data and stores with GDPR compliance
+;;;; Polls cl-streamer for listener data and stores with GDPR compliance
(in-package #:asteroid)
@@ -7,7 +7,7 @@
;;; Configuration
(defvar *stats-polling-interval* 60
- "Seconds between Icecast polls")
+ "Seconds between listener count polls")
(defvar *stats-polling-thread* nil
"Background thread for polling")
@@ -15,15 +15,6 @@
(defvar *stats-polling-active* nil
"Flag to control polling loop")
-(defvar *icecast-stats-url* "http://localhost:8000/admin/stats"
- "Icecast admin stats endpoint (XML)")
-
-(defvar *icecast-admin-user* "admin"
- "Icecast admin username")
-
-(defvar *icecast-admin-pass* "asteroid_admin_2024"
- "Icecast admin password")
-
(defvar *geoip-api-url* "http://ip-api.com/json/~a?fields=status,countryCode,city,regionName"
"GeoIP lookup API (free tier: 45 req/min)")
@@ -144,76 +135,7 @@
(log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
nil)))
-;;; Icecast Polling
-
-(defun extract-xml-value (xml tag)
- "Extract value between XML tags. Simple regex-based extraction."
- (let ((pattern (format nil "<~a>([^<]*)~a>" tag tag)))
- (multiple-value-bind (match groups)
- (cl-ppcre:scan-to-strings pattern xml)
- (when match
- (aref groups 0)))))
-
-(defun extract-xml-sources (xml)
- "Extract all source blocks from Icecast XML"
- (let ((sources nil)
- (pattern "(.*?) "))
- (cl-ppcre:do-register-groups (mount content) (pattern xml)
- (let ((listeners (extract-xml-value content "listeners"))
- (listener-peak (extract-xml-value content "listener_peak"))
- (server-name (extract-xml-value content "server_name")))
- (push (list :mount mount
- :server-name server-name
- :listeners (if listeners (parse-integer listeners :junk-allowed t) 0)
- :listener-peak (if listener-peak (parse-integer listener-peak :junk-allowed t) 0))
- sources)))
- (nreverse sources)))
-
-(defun fetch-icecast-stats ()
- "Fetch current statistics from Icecast admin XML endpoint"
- (handler-case
- (let ((response (drakma:http-request *icecast-stats-url*
- :want-stream nil
- :connection-timeout 5
- :basic-authorization (list *icecast-admin-user*
- *icecast-admin-pass*))))
- ;; Response is XML, return as string for parsing
- (if (stringp response)
- response
- (babel:octets-to-string response :encoding :utf-8)))
- (error (e)
- (log:warn "Failed to fetch Icecast stats: ~a" e)
- nil)))
-
-(defun parse-icecast-sources (xml-string)
- "Parse Icecast XML stats and extract source/mount information.
- Returns list of plists with mount info."
- (when xml-string
- (extract-xml-sources xml-string)))
-
-(defun fetch-icecast-listclients (mount)
- "Fetch listener list for a specific mount from Icecast admin"
- (handler-case
- (let* ((url (format nil "http://localhost:8000/admin/listclients?mount=~a" mount))
- (response (drakma:http-request url
- :want-stream nil
- :connection-timeout 5
- :basic-authorization (list *icecast-admin-user*
- *icecast-admin-pass*))))
- (if (stringp response)
- response
- (babel:octets-to-string response :encoding :utf-8)))
- (error (e)
- (log:debug "Failed to fetch listclients for ~a: ~a" mount e)
- nil)))
-
-(defun extract-listener-ips (xml-string)
- "Extract listener IPs from Icecast listclients XML"
- (let ((ips nil)
- (pattern "([^<]+) "))
- (cl-ppcre:do-register-groups (ip) (pattern xml-string)
- (push ip ips))
- (nreverse ips)))
+;;; Listener Polling
;;; Database Operations
@@ -439,21 +361,6 @@
(list :country country :city city :time (get-universal-time)))
(cons country city)))))))
-(defun collect-geo-stats-for-mount (mount)
- "Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
- (let ((listclients-xml (fetch-icecast-listclients mount)))
- (when listclients-xml
- (let ((ips (extract-listener-ips listclients-xml))
- (location-counts (make-hash-table :test 'equal)))
- ;; Group by country+city
- (dolist (ip ips)
- (let ((geo (get-cached-geo ip))) ; Returns (country . city) or nil
- (when geo
- (incf (gethash geo location-counts 0)))))
- ;; Store each country+city count
- (maphash (lambda (key count)
- (update-geo-stats (car key) count (cdr key)))
- location-counts)))))
(defun collect-geo-stats-from-web-listeners ()
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
@@ -476,25 +383,12 @@
location-counts)))
(defun poll-and-store-stats ()
- "Single poll iteration: fetch stats and store.
- Uses cl-streamer listener counts when Harmony is running, falls back to Icecast."
- (if *harmony-pipeline*
- ;; Get listener counts directly from cl-streamer
- (dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
- (let ((listeners (cl-streamer:get-listener-count mount)))
- (when (and listeners (> listeners 0))
- (store-listener-snapshot mount listeners)
- (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
- ;; Fallback: poll Icecast
- (let ((stats (fetch-icecast-stats)))
- (when stats
- (let ((sources (parse-icecast-sources stats)))
- (dolist (source sources)
- (let ((mount (getf source :mount))
- (listeners (getf source :listeners)))
- (when mount
- (store-listener-snapshot mount listeners)
- (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
+ "Single poll iteration: fetch listener counts from cl-streamer and store."
+ (dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
+ (let ((listeners (cl-streamer:get-listener-count mount)))
+ (when (and listeners (> listeners 0))
+ (store-listener-snapshot mount listeners)
+ (log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
;; Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)
(collect-geo-stats-from-web-listeners))
diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp
index 02086e4..7a11aab 100644
--- a/parenscript/admin.lisp
+++ b/parenscript/admin.lisp
@@ -26,12 +26,12 @@
(setup-event-listeners)
(load-playlist-list)
(load-current-queue)
- (refresh-liquidsoap-status)
+ (refresh-stream-status)
(setup-stats-refresh)
(refresh-scheduler-status)
(refresh-track-requests)
- ;; Update Liquidsoap status every 10 seconds
- (set-interval refresh-liquidsoap-status 10000)
+ ;; Update stream status every 10 seconds
+ (set-interval refresh-stream-status 10000)
;; Update scheduler status every 30 seconds
(set-interval refresh-scheduler-status 30000))))
@@ -104,24 +104,19 @@
(when refresh-playlists-btn
(ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
- ;; Liquidsoap controls
+ ;; Stream controls
(let ((ls-refresh-btn (ps:chain document (get-element-by-id "ls-refresh-status")))
(ls-skip-btn (ps:chain document (get-element-by-id "ls-skip")))
(ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
(ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
(when ls-refresh-btn
- (ps:chain ls-refresh-btn (add-event-listener "click" refresh-liquidsoap-status)))
+ (ps:chain ls-refresh-btn (add-event-listener "click" refresh-stream-status)))
(when ls-skip-btn
- (ps:chain ls-skip-btn (add-event-listener "click" liquidsoap-skip)))
+ (ps:chain ls-skip-btn (add-event-listener "click" stream-skip)))
(when ls-reload-btn
- (ps:chain ls-reload-btn (add-event-listener "click" liquidsoap-reload)))
+ (ps:chain ls-reload-btn (add-event-listener "click" stream-reload)))
(when ls-restart-btn
- (ps:chain ls-restart-btn (add-event-listener "click" liquidsoap-restart))))
-
- ;; Icecast restart
- (let ((icecast-restart-btn (ps:chain document (get-element-by-id "icecast-restart"))))
- (when icecast-restart-btn
- (ps:chain icecast-restart-btn (add-event-listener "click" icecast-restart)))))
+ (ps:chain ls-restart-btn (add-event-listener "click" stream-restart)))))
;; Load tracks from API
(defun load-tracks ()
@@ -697,7 +692,7 @@
(when container
(if (= (ps:@ tracks length) 0)
(setf (ps:@ container inner-h-t-m-l)
- "Queue is empty. Liquidsoap will use random playback from the music library.
")
+ "Queue is empty.
")
(let ((html ""))
(ps:chain tracks
(for-each (lambda (track index)
@@ -775,7 +770,7 @@
;; Clear stream queue (updated to use new API)
(defun clear-stream-queue ()
- (unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
+ (unless (confirm "Clear the stream queue?")
(return))
(ps:chain
@@ -793,13 +788,13 @@
(alert "Error clearing queue")))))
;; ========================================
- ;; Liquidsoap Control Functions
+ ;; Stream Control Functions
;; ========================================
- ;; Refresh Liquidsoap status
- (defun refresh-liquidsoap-status ()
+ ;; Refresh stream status
+ (defun refresh-stream-status ()
(ps:chain
- (fetch "/api/asteroid/liquidsoap/status")
+ (fetch "/api/asteroid/stream/status")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
@@ -814,28 +809,28 @@
(when metadata-el
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
(catch (lambda (error)
- (ps:chain console (error "Error fetching Liquidsoap status:" error))))))
+ (ps:chain console (error "Error fetching stream status:" error))))))
;; Skip current track
- (defun liquidsoap-skip ()
+ (defun stream-skip ()
(ps:chain
- (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST"))
+ (fetch "/api/asteroid/stream/skip" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast "⏭️ Track skipped")
- (set-timeout refresh-liquidsoap-status 1000))
+ (set-timeout refresh-stream-status 1000))
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error skipping track:" error))
(alert "Error skipping track")))))
;; Reload playlist
- (defun liquidsoap-reload ()
+ (defun stream-reload ()
(ps:chain
- (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
+ (fetch "/api/asteroid/stream/reload" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
@@ -846,44 +841,25 @@
(ps:chain console (error "Error reloading playlist:" error))
(alert "Error reloading playlist")))))
- ;; Restart Liquidsoap container
- (defun liquidsoap-restart ()
- (unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
+ ;; Restart streaming pipeline
+ (defun stream-restart ()
+ (unless (confirm "Restart the streaming pipeline? This will cause a brief interruption.")
(return))
- (show-toast "🔄 Restarting Liquidsoap...")
+ (show-toast "🔄 Restarting stream...")
(ps:chain
- (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST"))
+ (fetch "/api/asteroid/stream/restart" (ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
- (show-toast "✓ Liquidsoap restarting")
- ;; Refresh status after a delay to let container restart
- (set-timeout refresh-liquidsoap-status 5000))
- (alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
+ (show-toast "✓ Stream restarting")
+ (set-timeout refresh-stream-status 5000))
+ (alert (+ "Error restarting stream: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
- (ps:chain console (error "Error restarting Liquidsoap:" error))
- (alert "Error restarting Liquidsoap")))))
-
- ;; Restart Icecast container
- (defun icecast-restart ()
- (unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.")
- (return))
-
- (show-toast "🔄 Restarting Icecast...")
- (ps:chain
- (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST"))
- (then (lambda (response) (ps:chain response (json))))
- (then (lambda (result)
- (let ((data (or (ps:@ result data) result)))
- (if (= (ps:@ data status) "success")
- (show-toast "✓ Icecast restarting - listeners will reconnect automatically")
- (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
- (catch (lambda (error)
- (ps:chain console (error "Error restarting Icecast:" error))
- (alert "Error restarting Icecast")))))
+ (ps:chain console (error "Error restarting stream:" error))
+ (alert "Error restarting stream")))))
;; ========================================
;; Listener Statistics
diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp
index 3922f0e..7bc3616 100644
--- a/playlist-scheduler.lisp
+++ b/playlist-scheduler.lisp
@@ -33,61 +33,17 @@
(let ((current-hour (local-time:timestamp-hour (local-time:now) :timezone local-time:+utc-zone+)))
(get-scheduled-playlist-for-hour current-hour)))
-(defun liquidsoap-command-succeeded-p (result)
- "Check if a liquidsoap-command result indicates success.
- Returns NIL if the result is empty, an error string, or otherwise invalid."
- (and result
- (stringp result)
- (> (length (string-trim '(#\Space #\Newline #\Return) result)) 0)
- (not (search "Error:" result :test #'char-equal))))
-
-(defun liquidsoap-reload-and-skip (&key (max-retries 3) (retry-delay 2))
- "Reload the playlist and skip the current track in Liquidsoap with retries.
- First reloads the playlist file, then skips to trigger crossfade.
- Retries up to MAX-RETRIES times with RETRY-DELAY seconds between attempts."
- (let ((reload-ok nil)
- (skip-ok nil))
- ;; Step 1: Reload the playlist file in Liquidsoap
- (dotimes (attempt max-retries)
- (let ((result (liquidsoap-command "stream-queue_m3u.reload")))
- (when (liquidsoap-command-succeeded-p result)
- (setf reload-ok t)
- (return)))
- (when (< attempt (1- max-retries))
- (sleep retry-delay)))
- ;; Step 2: Skip current track to trigger crossfade to new playlist
- (when reload-ok
- (sleep 1)) ; Brief pause after reload before skipping
- (dotimes (attempt max-retries)
- (let ((result (liquidsoap-command "stream-queue_m3u.skip")))
- (when (liquidsoap-command-succeeded-p result)
- (setf skip-ok t)
- (return)))
- (when (< attempt (1- max-retries))
- (sleep retry-delay)))
- (values skip-ok reload-ok)))
-
(defun load-scheduled-playlist (playlist-name)
- "Load a playlist by name and trigger playback.
- Uses Harmony pipeline when available, falls back to Liquidsoap."
+ "Load a playlist by name and trigger playback via the Harmony pipeline."
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
(if (probe-file playlist-path)
(progn
(copy-playlist-to-stream-queue playlist-path)
(load-queue-from-m3u-file)
- (if *harmony-pipeline*
- ;; Use cl-streamer directly
- (let ((count (harmony-load-playlist playlist-path)))
- (if count
- (log:info "Scheduler loaded ~a (~a tracks via Harmony)" playlist-name count)
- (log:error "Scheduler failed to load ~a via Harmony" playlist-name)))
- ;; Fallback to Liquidsoap
- (multiple-value-bind (skip-ok reload-ok)
- (liquidsoap-reload-and-skip)
- (if (and reload-ok skip-ok)
- (log:info "Scheduler loaded ~a" playlist-name)
- (log:error "Scheduler failed to switch to ~a (reload:~a skip:~a)"
- playlist-name reload-ok skip-ok))))
+ (let ((count (harmony-load-playlist playlist-path)))
+ (if count
+ (log:info "Scheduler loaded ~a (~a tracks)" playlist-name count)
+ (log:error "Scheduler failed to load ~a" playlist-name)))
t)
(progn
(log:error "Scheduler playlist not found: ~a" playlist-name)
diff --git a/stream-control.lisp b/stream-control.lisp
index fa5e118..985e914 100644
--- a/stream-control.lisp
+++ b/stream-control.lisp
@@ -1,5 +1,5 @@
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
-;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
+;;;; Manages the main broadcast stream queue and generates M3U playlists
(in-package :asteroid)
@@ -91,8 +91,8 @@
(defun regenerate-stream-playlist ()
"Regenerate the main stream playlist from the current queue.
- NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u
- which is what Liquidsoap actually reads. This function may be deprecated."
+ NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u.
+ This function may be deprecated."
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
(asdf:system-source-directory :asteroid))))
(if (null *stream-queue*)
diff --git a/stream-harmony.lisp b/stream-harmony.lisp
index 800e7fd..ef5b572 100644
--- a/stream-harmony.lisp
+++ b/stream-harmony.lisp
@@ -1,5 +1,5 @@
;;;; stream-harmony.lisp - CL-Streamer / Harmony integration for Asteroid Radio
-;;;; Replaces the Icecast + Liquidsoap stack with in-process audio streaming.
+;;;; In-process audio streaming via Harmony + cl-streamer.
;;;; Provides the same data interface to frontend-partials and admin APIs.
(in-package :asteroid)
@@ -106,7 +106,14 @@
(and (> (length trimmed) 0) (char= (char trimmed 0) #\#)))
collect (convert-from-docker-path trimmed)))))
-;;; ---- Track Change Callback ----
+;;; ---- Track & Playlist Change Callbacks ----
+
+(defun on-harmony-playlist-change (pipeline playlist-path)
+ "Called by cl-streamer when a scheduler playlist actually starts playing.
+ Updates *current-playlist-path* only now, not at queue time."
+ (declare (ignore pipeline))
+ (setf *current-playlist-path* playlist-path)
+ (log:info "Playlist now active: ~A" (file-namestring playlist-path)))
(defun on-harmony-track-change (pipeline track-info)
"Called by cl-streamer when a track changes.
@@ -147,13 +154,11 @@
(error () nil))))
;;; ---- Now-Playing Data Source ----
-;;; These functions provide the same data that icecast-now-playing returned,
-;;; but sourced directly from cl-streamer's pipeline state.
+;;; These functions provide now-playing data from cl-streamer's pipeline state.
(defun harmony-now-playing (&optional (mount "asteroid.mp3"))
"Get now-playing information from cl-streamer pipeline.
- Returns an alist compatible with the icecast-now-playing format,
- or NIL if the pipeline is not running."
+ Returns an alist with now-playing data, or NIL if the pipeline is not running."
(when (and *harmony-pipeline*
(cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(let* ((track-info (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
@@ -217,6 +222,10 @@
(setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*)
#'on-harmony-track-change)
+ ;; Set the playlist-change callback (fires when scheduler playlist actually starts)
+ (setf (cl-streamer/harmony:pipeline-on-playlist-change *harmony-pipeline*)
+ #'on-harmony-playlist-change)
+
;; Start the audio pipeline
(cl-streamer/harmony:start-pipeline *harmony-pipeline*)
@@ -237,7 +246,7 @@
(cl-streamer:stop)
(log:info "Harmony streaming stopped"))
-;;; ---- Playlist Control (replaces Liquidsoap commands) ----
+;;; ---- Playlist Control ----
(defun harmony-load-playlist (m3u-path &key (skip nil))
"Load and start playing an M3U playlist through the Harmony pipeline.
@@ -247,8 +256,11 @@
(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))
+ ;; Store pending playlist path on pipeline — it will be applied
+ ;; when drain-queue-into-remaining fires and the new tracks
+ ;; actually start playing, not now at queue time.
+ (setf (cl-streamer/harmony:pipeline-pending-playlist-path *harmony-pipeline*)
+ (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*
@@ -268,7 +280,7 @@
t))
(defun harmony-get-status ()
- "Get current pipeline status (replaces liquidsoap status)."
+ "Get current pipeline status."
(if *harmony-pipeline*
(let ((track (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
(listeners (cl-streamer:get-listener-count)))
diff --git a/template/about.ctml b/template/about.ctml
index 8da4661..03da3f0 100644
--- a/template/about.ctml
+++ b/template/about.ctml
@@ -47,8 +47,8 @@
Clip - HTML5-compliant template engine
LASS - Lisp Augmented Style Sheets
ParenScript - Lisp-to-JavaScript compiler
-
Icecast - Streaming media server
-
Liquidsoap - Audio stream generation
+
Harmony - Common Lisp audio system
+
CL-Mixed - Audio mixing and processing
By building in Common Lisp, we're doubling down on our technical values and creating features
diff --git a/template/admin.ctml b/template/admin.ctml
index a3baa35..0cf9d86 100644
--- a/template/admin.ctml
+++ b/template/admin.ctml
@@ -26,13 +26,8 @@
🟢 Connected
-
Liquidsoap Status
-
🔴 Not Running
-
-
-
Icecast Status
-
🔴 Not Running
-
🔄 Restart
+
Stream Status
+
� Running
@@ -127,7 +122,7 @@
Music Library
-
The music library is mounted from your local filesystem into the Liquidsoap container.
+
The music library is loaded from your local filesystem.
To add music:
Add files to your music library directory (set via MUSIC_LIBRARY env var)
@@ -183,7 +178,7 @@
🎵 Stream Queue Management
-
Manage the live stream playback queue. Liquidsoap watches stream-queue.m3u and reloads automatically.
+
Manage the live stream playback queue.
@@ -196,7 +191,7 @@
🔄 Refresh List
- Loading a playlist will copy it to stream-queue.m3u and Liquidsoap will start playing it.
+ Loading a playlist will queue it for playback via the Harmony pipeline.
@@ -328,13 +323,13 @@
-
+
-
📡 Stream Control (Liquidsoap)
-
Control the live audio stream. Commands are sent directly to Liquidsoap.
+
📡 Stream Control
+
Control the live audio stream via the Harmony pipeline.
-
+
Uptime: --
@@ -353,13 +348,13 @@
🔄 Refresh Status
⏭️ Skip Track
📂 Reload Playlist
- 🔄 Restart Container
+ 🔄 Restart Pipeline
- Skip Track: Immediately skip to the next track in the playlist.
- Reload Playlist: Force Liquidsoap to re-read stream-queue.m3u.
- Restart Container: Restart the Liquidsoap Docker container (causes brief stream interruption).
+ Skip Track: Crossfade to the next track in the playlist.
+ Reload Playlist: Re-read stream-queue.m3u into the pipeline.
+ Restart Pipeline: Restart the Harmony streaming pipeline (causes brief stream interruption).
diff --git a/template/status.ctml b/template/status.ctml
index 999eece..25a9f8d 100644
--- a/template/status.ctml
+++ b/template/status.ctml
@@ -31,7 +31,7 @@
Status: 🟢 Live
Formats: AAC 96kbps, MP3 128kbps, MP3 64kbps
- Server: Icecast
+ Server: CL-Streamer / Harmony