Remove Icecast/Liquidsoap, migrate fully to Harmony/CL-Streamer

- Delete all Icecast/Liquidsoap config files, Dockerfiles, and .liq scripts
- Remove icecast/liquidsoap services from docker-compose.yml (keep postgres)
- Remove liquidsoap-command, parse-liquidsoap-metadata, format-remaining-time
- Remove liquidsoap-command-succeeded-p, liquidsoap-reload-and-skip
- Remove icecast-now-playing, check-icecast-status, check-liquidsoap-status
- Remove icecast XML polling from listener-stats.lisp
- Replace asteroid/liquidsoap/* APIs with asteroid/stream/* APIs
- Simplify all if-harmony-else-liquidsoap branches to Harmony-only
- Update admin template: single Stream Status card, pipeline controls
- Update about.ctml credits: Harmony + CL-Mixed replace Icecast + Liquidsoap
- Update status.ctml server name to CL-Streamer / Harmony
- Update parenscript/admin.lisp: stream-* functions, new API endpoints
- Export pipeline-running-p from cl-streamer/harmony package
This commit is contained in:
Glenn Thompson 2026-03-05 17:24:12 +03:00
parent 47e6c5da46
commit 1807e58971
22 changed files with 168 additions and 1009 deletions

View File

@ -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/")

View File

@ -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 "<source mount=\"/asteroid\\.mp3\">" xml-string)
(declare (ignore match-end))
(if match-start
(let* ((source-section (subseq xml-string match-start
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
(length xml-string))))
(titlep (cl-ppcre:all-matches "<title>" source-section))
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" 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

View File

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

View File

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

View File

@ -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"]

View File

@ -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"]

View File

@ -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")

View File

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

View File

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

View File

@ -1,61 +0,0 @@
<icecast>
<location>Asteroid Radio</location>
<admin>admin@asteroid.radio</admin>
<limits>
<clients>100</clients>
<sources>5</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>H1tn31EhsyLrfRmo</source-password>
<relay-password>asteroid_relay_2024</relay-password>
<admin-user>admin</admin-user>
<admin-password>asteroid_admin_2024</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
</listen-socket>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/var/log/icecast</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
</logging>
<security>
<chroot>0</chroot>
<changeowner>
<user>icecast</user>
<group>icecast</group>
</changeowner>
</security>
<!-- CORS headers for Web Audio API -->
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
<header name="Access-Control-Allow-Headers" value="Origin, Accept, X-Requested-With, Content-Type" />
<header name="Access-Control-Allow-Methods" value="GET, OPTIONS, HEAD" />
</http-headers>
</icecast>

View File

@ -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|<hostname>localhost</hostname>|<hostname>$ICECAST_HOSTNAME</hostname>|" /etc/icecast.xml
if [ "$ICECAST_ENABLE_YP" = "true" ]; then
echo "YP directory publishing ENABLED"
# Insert YP config before closing </icecast> 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 "</icecast>" >> /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

View File

@ -1,9 +0,0 @@
<!-- YP Directory listings (production only) -->
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://icecast-yp.internet-radio.com</yp-url>
</directory>
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://dir.xiph.org/cgi-bin/yp-cgi</yp-url>
</directory>

View File

@ -1,71 +0,0 @@
<icecast>
<location>Asteroid Radio</location>
<admin>admin@asteroid.radio</admin>
<limits>
<clients>100</clients>
<sources>5</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>H1tn31EhsyLrfRmo</source-password>
<relay-password>asteroid_relay_2024</relay-password>
<admin-user>admin</admin-user>
<admin-password>asteroid_admin_2024</admin-password>
</authentication>
<hostname>localhost</hostname>
<!-- YP Directory listings -->
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://icecast-yp.internet-radio.com</yp-url>
</directory>
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://dir.xiph.org/cgi-bin/yp-cgi</yp-url>
</directory>
<listen-socket>
<port>8000</port>
</listen-socket>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/var/log/icecast</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
</logging>
<security>
<chroot>0</chroot>
<changeowner>
<user>icecast</user>
<group>icecast</group>
</changeowner>
</security>
<!-- CORS headers for Web Audio API -->
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
<header name="Access-Control-Allow-Headers" value="Origin, Accept, X-Requested-With, Content-Type" />
<header name="Access-Control-Allow-Methods" value="GET, OPTIONS, HEAD" />
</http-headers>
</icecast>

View File

@ -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 <listeners> tag (sums all mount points)
;; Extract title from specified mount point
(let* ((total-listeners (multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" 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 "<source mount=\"/~a\">"
(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 "</source>" xml-string :start mount-start)
(length xml-string)))))
(multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" 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)))

View File

@ -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 "<source mount=\"([^\"]+)\">(.*?)</source>"))
(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 "<IP>([^<]+)</IP>"))
(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))

View File

@ -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)
"<div class=\"empty-state\">Queue is empty. Liquidsoap will use random playback from the music library.</div>")
"<div class=\"empty-state\">Queue is empty.</div>")
(let ((html "<div class=\"queue-items\">"))
(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

View File

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

View File

@ -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*)

View File

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

View File

@ -47,8 +47,8 @@
<li><strong><a href="https://codeberg.org/shinmera/clip" style="color: #00ff00;">Clip</a></strong> - HTML5-compliant template engine</li>
<li><strong><a href="https://codeberg.org/shinmera/LASS" style="color: #00ff00;">LASS</a></strong> - Lisp Augmented Style Sheets</li>
<li><strong><a href="https://gitlab.common-lisp.net/parenscript/parenscript" style="color: #00ff00;">ParenScript</a></strong> - Lisp-to-JavaScript compiler</li>
<li><strong><a href="https://icecast.org/" style="color: #00ff00;">Icecast</a></strong> - Streaming media server</li>
<li><strong><a href="https://www.liquidsoap.info/" style="color: #00ff00;">Liquidsoap</a></strong> - Audio stream generation</li>
<li><strong><a href="https://shirakumo.github.io/harmony/" style="color: #00ff00;">Harmony</a></strong> - Common Lisp audio system</li>
<li><strong><a href="https://shirakumo.github.io/cl-mixed/" style="color: #00ff00;">CL-Mixed</a></strong> - Audio mixing and processing</li>
</ul>
<p style="line-height: 1.6;">
By building in Common Lisp, we're doubling down on our technical values and creating features

View File

@ -26,13 +26,8 @@
<p class="status-good" data-text="database-status">🟢 Connected</p>
</div>
<div class="status-card">
<h3>Liquidsoap Status</h3>
<p class="status-error" data-text="liquidsoap-status">🔴 Not Running</p>
</div>
<div class="status-card">
<h3>Icecast Status</h3>
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
<button id="icecast-restart" class="btn btn-danger btn-sm" style="margin-top: 8px;">🔄 Restart</button>
<h3>Stream Status</h3>
<p class="status-good" data-text="stream-status"><3E> Running</p>
</div>
</div>
</div>
@ -127,7 +122,7 @@
<div class="upload-section">
<h3>Music Library</h3>
<div class="upload-info">
<p>The music library is mounted from your local filesystem into the Liquidsoap container.</p>
<p>The music library is loaded from your local filesystem.</p>
<p><strong>To add music:</strong></p>
<ol>
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
@ -183,7 +178,7 @@
<!-- Stream Queue Management -->
<div class="admin-section">
<h2>🎵 Stream Queue Management</h2>
<p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p>
<p>Manage the live stream playback queue.</p>
<!-- Playlist Selection -->
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
@ -196,7 +191,7 @@
<button id="refresh-playlists-btn" class="btn btn-secondary">🔄 Refresh List</button>
</div>
<p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;">
Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it.
Loading a playlist will queue it for playback via the Harmony pipeline.
</p>
</div>
@ -328,13 +323,13 @@
</p>
</div>
<!-- Liquidsoap Stream Control -->
<!-- Stream Control -->
<div class="admin-section">
<h2>📡 Stream Control (Liquidsoap)</h2>
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
<h2>📡 Stream Control</h2>
<p>Control the live audio stream via the Harmony pipeline.</p>
<!-- Status Display -->
<div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
<div id="stream-status-detail" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
@ -353,13 +348,13 @@
<button id="ls-refresh-status" class="btn btn-secondary">🔄 Refresh Status</button>
<button id="ls-skip" class="btn btn-warning">⏭️ Skip Track</button>
<button id="ls-reload" class="btn btn-info">📂 Reload Playlist</button>
<button id="ls-restart" class="btn btn-danger">🔄 Restart Container</button>
<button id="ls-restart" class="btn btn-danger">🔄 Restart Pipeline</button>
</div>
<p style="font-size: 0.9em; color: #888;">
<strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br>
<strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br>
<strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption).
<strong>Skip Track:</strong> Crossfade to the next track in the playlist.<br>
<strong>Reload Playlist:</strong> Re-read stream-queue.m3u into the pipeline.<br>
<strong>Restart Pipeline:</strong> Restart the Harmony streaming pipeline (causes brief stream interruption).
</p>
</div>

View File

@ -31,7 +31,7 @@
<ul style="line-height: 1.8;">
<li><strong>Status:</strong> 🟢 Live</li>
<li><strong>Formats:</strong> AAC 96kbps, MP3 128kbps, MP3 64kbps</li>
<li><strong>Server:</strong> Icecast</li>
<li><strong>Server:</strong> CL-Streamer / Harmony</li>
</ul>
</section>