Compare commits

..

2 Commits

Author SHA1 Message Date
Glenn Thompson f39abeb8f8 Fix playlist resume and SIMPLE-ARRAY pathname errors
- Add *resumed-from-saved-state* flag to prevent scheduler's db:connected
  trigger from overwriting resumed playlist position with full playlist
- Use sb-ext:parse-native-namestring in play-file to prevent SBCL from
  interpreting brackets in directory names (e.g. [WEB FLAC]) as wildcard
  patterns, which caused non-simple-string pathname components that broke
  cl-flac's CFFI calls
2026-03-05 18:21:32 +03:00
Glenn Thompson 1807e58971 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
2026-03-05 17:24:12 +03:00
22 changed files with 194 additions and 1016 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))))
(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,58 +517,12 @@
("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")
@ -581,76 +530,33 @@
("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)))))))))
("queue_length" . ,(getf status :queue-length)))))))
(define-api asteroid/liquidsoap/skip () ()
"Skip the current track"
(define-api asteroid/stream/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))))))))
("message" . "Track skipped")))))
(define-api asteroid/liquidsoap/reload () ()
"Force playlist reload"
(define-api asteroid/stream/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))))))))
("message" . ,(format nil "Playlist reloaded (~A tracks)" count)))))))
(define-api asteroid/liquidsoap/restart () ()
"Restart the streaming backend"
(define-api asteroid/stream/restart () ()
"Restart the streaming pipeline."
(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)))
(api-output `(("status" . "success")
("message" . "Icecast container restarting")
("result" . ,result))))))
("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))))))
("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."
@ -280,12 +290,19 @@
FILE-PATH can be a string or pathname.
ON-END is passed to harmony:play (default :free).
UPDATE-METADATA controls whether ICY metadata is updated immediately."
(let* ((path (pathname file-path))
(let* ((path-string (etypecase file-path
(string file-path)
(pathname (namestring file-path))))
;; Use parse-native-namestring to prevent SBCL from interpreting
;; brackets as wildcard patterns. Standard (pathname ...) turns
;; "[FLAC]" into a wild component with non-simple strings, which
;; causes SIMPLE-ARRAY errors in cl-flac's CFFI calls.
(path (sb-ext:parse-native-namestring path-string))
(server (pipeline-harmony-server pipeline))
(harmony:*server* server)
(tags (read-audio-metadata path))
(display-title (format-display-title path title))
(track-info (list :file (namestring path)
(track-info (list :file path-string
:display-title display-title
:artist (getf tags :artist)
:title (getf tags :title)
@ -337,6 +354,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
"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))))
;; 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))))))))
;; 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))))
(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)
@ -356,14 +312,21 @@
;;; This ensures the scheduler starts after the server is fully initialized
(define-trigger db:connected ()
"Start the playlist scheduler after database connection is established"
"Start the playlist scheduler after database connection is established.
Loads the current scheduled playlist only if the pipeline has no tracks
(i.e., we did NOT just resume from saved state)."
(handler-case
(progn
(load-schedule-from-db)
(start-playlist-scheduler)
;; Only load scheduled playlist if we didn't just resume from saved state
(if *resumed-from-saved-state*
(progn
(setf *resumed-from-saved-state* nil)
(log:info "Playlist scheduler started (resumed from saved state, skipping initial load)"))
(let ((current-playlist (get-current-scheduled-playlist)))
(when current-playlist
(load-scheduled-playlist current-playlist)))
(log:info "Playlist scheduler started"))
(load-scheduled-playlist current-playlist))
(log:info "Playlist scheduler started"))))
(error (e)
(log:error "Scheduler failed to start: ~a" e))))

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)
@ -27,6 +27,10 @@
(defvar *current-playlist-path* nil
"Path of the currently active playlist file.")
(defvar *resumed-from-saved-state* nil
"Set to T when startup successfully resumed from saved playback state.
Prevents the scheduler from overwriting the resumed position.")
(defun save-playback-state (track-file-path)
"Save the current track file path and playlist to the state file.
Called on each track change so we can resume after restart."
@ -72,6 +76,7 @@
(m3u-to-file-list playlist-path))))
(when file-list
(setf *current-playlist-path* playlist-path)
(setf *resumed-from-saved-state* t)
(let ((pos (when saved-file
(position saved-file file-list :test #'string=))))
(if pos
@ -106,7 +111,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 +159,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 +227,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 +251,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 +261,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 +285,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>