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 "<listeners>" source-section)) - (title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?).*" 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>([^<]*)" 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

    - +

    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:

    1. Add files to your music library directory (set via MUSIC_LIBRARY env var)
    2. @@ -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 @@

      - 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 @@ - +

    - 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