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:
parent
47e6c5da46
commit
1807e58971
|
|
@ -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/")
|
|
||||||
196
asteroid.lisp
196
asteroid.lisp
|
|
@ -443,12 +443,7 @@
|
||||||
(let ((count (load-queue-from-m3u-file))
|
(let ((count (load-queue-from-m3u-file))
|
||||||
(channel-name (get-curated-channel-name)))
|
(channel-name (get-curated-channel-name)))
|
||||||
;; Skip/switch to new playlist
|
;; Skip/switch to new playlist
|
||||||
(if *harmony-pipeline*
|
|
||||||
(harmony-load-playlist playlist-path)
|
(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")
|
(api-output `(("status" . "success")
|
||||||
("message" . ,(format nil "Loaded playlist: ~a" name))
|
("message" . ,(format nil "Loaded playlist: ~a" name))
|
||||||
("count" . ,count)
|
("count" . ,count)
|
||||||
|
|
@ -480,7 +475,7 @@
|
||||||
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
|
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
|
||||||
|
|
||||||
(define-api asteroid/stream/playlists/clear () ()
|
(define-api asteroid/stream/playlists/clear () ()
|
||||||
"Clear stream-queue.m3u (Liquidsoap will fall back to random)"
|
"Clear stream-queue.m3u"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(let ((stream-queue-path (get-stream-queue-path)))
|
(let ((stream-queue-path (get-stream-queue-path)))
|
||||||
|
|
@ -492,7 +487,7 @@
|
||||||
;; Clear in-memory queue
|
;; Clear in-memory queue
|
||||||
(setf *stream-queue* '())
|
(setf *stream-queue* '())
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Stream queue cleared - Liquidsoap will use random playback"))))))
|
("message" . "Stream queue cleared"))))))
|
||||||
|
|
||||||
(define-api asteroid/stream/playlists/current () ()
|
(define-api asteroid/stream/playlists/current () ()
|
||||||
"Get current stream-queue.m3u contents with track info"
|
"Get current stream-queue.m3u contents with track info"
|
||||||
|
|
@ -522,58 +517,12 @@
|
||||||
("path" . ,docker-path)))))
|
("path" . ,docker-path)))))
|
||||||
paths)))))))
|
paths)))))))
|
||||||
|
|
||||||
;;; Liquidsoap Control APIs
|
;;; Stream Control APIs
|
||||||
;;; Control Liquidsoap via telnet interface on port 1234
|
|
||||||
|
|
||||||
(defun liquidsoap-command (command)
|
(define-api asteroid/stream/status () ()
|
||||||
"Send a command to Liquidsoap via telnet and return the response"
|
"Get stream status from Harmony pipeline."
|
||||||
(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"
|
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
|
||||||
(let ((status (harmony-get-status)))
|
(let ((status (harmony-get-status)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("backend" . "harmony")
|
("backend" . "harmony")
|
||||||
|
|
@ -581,76 +530,33 @@
|
||||||
("metadata" . ,(getf status :current-track))
|
("metadata" . ,(getf status :current-track))
|
||||||
("remaining" . "n/a")
|
("remaining" . "n/a")
|
||||||
("listeners" . ,(getf status :listeners))
|
("listeners" . ,(getf status :listeners))
|
||||||
("queue_length" . ,(getf status :queue-length)))))
|
("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 () ()
|
(define-api asteroid/stream/skip () ()
|
||||||
"Skip the current track"
|
"Skip the current track."
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
|
||||||
(progn
|
|
||||||
(harmony-skip-track)
|
(harmony-skip-track)
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Track skipped (Harmony)"))))
|
("message" . "Track skipped")))))
|
||||||
(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 () ()
|
(define-api asteroid/stream/reload () ()
|
||||||
"Force playlist reload"
|
"Force playlist reload."
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
|
||||||
(let* ((playlist-path (get-stream-queue-path))
|
(let* ((playlist-path (get-stream-queue-path))
|
||||||
(count (harmony-load-playlist playlist-path)))
|
(count (harmony-load-playlist playlist-path)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . ,(format nil "Playlist reloaded (~A tracks via Harmony)" count)))))
|
("message" . ,(format nil "Playlist reloaded (~A tracks)" 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 () ()
|
(define-api asteroid/stream/restart () ()
|
||||||
"Restart the streaming backend"
|
"Restart the streaming pipeline."
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
|
||||||
(progn
|
|
||||||
(stop-harmony-streaming)
|
(stop-harmony-streaming)
|
||||||
(start-harmony-streaming)
|
(start-harmony-streaming)
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Harmony pipeline restarted"))))
|
("message" . "Streaming 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))))))
|
|
||||||
|
|
||||||
(defun get-track-by-id (track-id)
|
(defun get-track-by-id (track-id)
|
||||||
"Get a track by its ID - handles type mismatches"
|
"Get a track by its ID - handles type mismatches"
|
||||||
|
|
@ -1009,32 +915,12 @@
|
||||||
(asdf:system-source-directory :asteroid))))))
|
(asdf:system-source-directory :asteroid))))))
|
||||||
|
|
||||||
;; Status check functions
|
;; Status check functions
|
||||||
(defun check-icecast-status ()
|
(defun check-stream-status ()
|
||||||
"Check if streaming backend is running.
|
"Check if the Harmony streaming pipeline is running."
|
||||||
Uses Harmony pipeline status when available, falls back to Icecast HTTP check."
|
(if (and *harmony-pipeline*
|
||||||
(if *harmony-pipeline*
|
(cl-streamer/harmony:pipeline-running-p *harmony-pipeline*))
|
||||||
"🟢 Running (cl-streamer)"
|
"🟢 Running (cl-streamer)"
|
||||||
(handler-case
|
"🔴 Not Running"))
|
||||||
(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"))))
|
|
||||||
|
|
||||||
;; Admin page (requires authentication)
|
;; Admin page (requires authentication)
|
||||||
(define-page admin #@"/admin" ()
|
(define-page admin #@"/admin" ()
|
||||||
|
|
@ -1051,8 +937,7 @@
|
||||||
:database-status (handler-case
|
:database-status (handler-case
|
||||||
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
|
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
|
||||||
(error () "🔴 No Database Backend"))
|
(error () "🔴 No Database Backend"))
|
||||||
:liquidsoap-status (check-liquidsoap-status)
|
:stream-status (check-stream-status)
|
||||||
:icecast-status (check-icecast-status)
|
|
||||||
:track-count (format nil "~d" track-count)
|
:track-count (format nil "~d" track-count)
|
||||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
|
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
|
||||||
:stream-base-url *stream-base-url*
|
:stream-base-url *stream-base-url*
|
||||||
|
|
@ -1382,48 +1267,17 @@
|
||||||
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
("stream-status" . "live"))))
|
("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 () ()
|
(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
|
(with-error-handling
|
||||||
(if *harmony-pipeline*
|
|
||||||
;; Return status from cl-streamer directly
|
|
||||||
(let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
|
(let* ((now-playing (get-now-playing-stats "asteroid.mp3"))
|
||||||
(title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
|
(title (if now-playing (cdr (assoc :title now-playing)) "Unknown"))
|
||||||
(listeners (or (cl-streamer:get-listener-count) 0)))
|
(listeners (or (cl-streamer:get-listener-count) 0)))
|
||||||
(api-output
|
(api-output
|
||||||
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
`(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
("title" . ,title)
|
("title" . ,title)
|
||||||
("listeners" . ,listeners))))))))
|
("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))))))
|
|
||||||
|
|
||||||
;;; Listener Statistics API Endpoints
|
;;; Listener Statistics API Endpoints
|
||||||
|
|
||||||
|
|
@ -1567,7 +1421,7 @@
|
||||||
;; TODO: Add auto-scan on startup once database timing issues are resolved
|
;; TODO: Add auto-scan on startup once database timing issues are resolved
|
||||||
;; For now, use the "Scan Library" button in the admin interface
|
;; 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...~%")
|
(format t "Starting cl-streamer audio pipeline...~%")
|
||||||
(handler-case
|
(handler-case
|
||||||
(progn
|
(progn
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,13 @@
|
||||||
;; Track state & control
|
;; Track state & control
|
||||||
#:pipeline-current-track
|
#:pipeline-current-track
|
||||||
#:pipeline-on-track-change
|
#:pipeline-on-track-change
|
||||||
|
#:pipeline-running-p
|
||||||
#:pipeline-skip
|
#:pipeline-skip
|
||||||
#:pipeline-queue-files
|
#:pipeline-queue-files
|
||||||
#:pipeline-get-queue
|
#:pipeline-get-queue
|
||||||
#:pipeline-clear-queue
|
#:pipeline-clear-queue
|
||||||
|
#:pipeline-pending-playlist-path
|
||||||
|
#:pipeline-on-playlist-change
|
||||||
;; Metadata helpers
|
;; Metadata helpers
|
||||||
#:read-audio-metadata
|
#:read-audio-metadata
|
||||||
#:format-display-title))
|
#:format-display-title))
|
||||||
|
|
@ -117,7 +120,12 @@
|
||||||
(queue-lock :initform (bt:make-lock "pipeline-queue-lock")
|
(queue-lock :initform (bt:make-lock "pipeline-queue-lock")
|
||||||
:reader pipeline-queue-lock)
|
:reader pipeline-queue-lock)
|
||||||
(skip-flag :initform nil :accessor pipeline-skip-flag
|
(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")
|
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
|
||||||
(sample-rate 44100) (channels 2))
|
(sample-rate 44100) (channels 2))
|
||||||
|
|
@ -218,9 +226,11 @@
|
||||||
|
|
||||||
(defun ensure-simple-string (s)
|
(defun ensure-simple-string (s)
|
||||||
"Coerce S to a simple-string if it's a string, or return NIL.
|
"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)
|
(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)
|
(defun safe-tag (fn audio-file)
|
||||||
"Safely read a tag field, coercing to simple-string. Returns NIL on any error."
|
"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
|
;; Replace remaining list and update current for loop-queue
|
||||||
(setf (car remaining-ref) all-queued)
|
(setf (car remaining-ref) all-queued)
|
||||||
(setf (car current-list-ref) (copy-list 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))))
|
t))))
|
||||||
|
|
||||||
(defun next-entry (pipeline remaining-ref current-list-ref)
|
(defun next-entry (pipeline remaining-ref current-list-ref)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
:initarg :stream-type
|
:initarg :stream-type
|
||||||
:reader error-stream-type
|
:reader error-stream-type
|
||||||
:initform nil
|
: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")
|
(:documentation "Signaled when stream operations fail")
|
||||||
(:report (lambda (condition stream)
|
(:report (lambda (condition stream)
|
||||||
(format stream "Stream Error~@[ (~a)~]: ~a"
|
(format stream "Stream Error~@[ (~a)~]: ~a"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,40 +1,4 @@
|
||||||
services:
|
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:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: asteroid-postgres
|
container_name: asteroid-postgres
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
(defun find-track-by-title (title)
|
(defun find-track-by-title (title)
|
||||||
"Find a track in the database by its title. Returns track ID or nil.
|
"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")))
|
(when (and title (not (string= title "Unknown")))
|
||||||
(handler-case
|
(handler-case
|
||||||
(with-db
|
(with-db
|
||||||
|
|
@ -35,74 +35,15 @@
|
||||||
(declare (ignore e))
|
(declare (ignore e))
|
||||||
nil))))
|
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"))
|
(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."
|
Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count."
|
||||||
(or (harmony-now-playing mount)
|
(harmony-now-playing mount))
|
||||||
(icecast-now-playing *stream-base-url* mount)))
|
|
||||||
|
|
||||||
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1)
|
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 10 :timeout 1)
|
||||||
"Get Partial HTML with live now-playing status.
|
"Get Partial HTML with live now-playing status.
|
||||||
Optional MOUNT parameter specifies which stream to get metadata from.
|
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
|
(with-error-handling
|
||||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
(now-playing-stats (get-now-playing-stats mount-name)))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
;;;; listener-stats.lisp - Listener Statistics Collection Service
|
;;;; 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)
|
(in-package #:asteroid)
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
;;; Configuration
|
;;; Configuration
|
||||||
(defvar *stats-polling-interval* 60
|
(defvar *stats-polling-interval* 60
|
||||||
"Seconds between Icecast polls")
|
"Seconds between listener count polls")
|
||||||
|
|
||||||
(defvar *stats-polling-thread* nil
|
(defvar *stats-polling-thread* nil
|
||||||
"Background thread for polling")
|
"Background thread for polling")
|
||||||
|
|
@ -15,15 +15,6 @@
|
||||||
(defvar *stats-polling-active* nil
|
(defvar *stats-polling-active* nil
|
||||||
"Flag to control polling loop")
|
"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"
|
(defvar *geoip-api-url* "http://ip-api.com/json/~a?fields=status,countryCode,city,regionName"
|
||||||
"GeoIP lookup API (free tier: 45 req/min)")
|
"GeoIP lookup API (free tier: 45 req/min)")
|
||||||
|
|
||||||
|
|
@ -144,76 +135,7 @@
|
||||||
(log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
|
(log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
;;; Icecast Polling
|
;;; Listener 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)))
|
|
||||||
|
|
||||||
;;; Database Operations
|
;;; Database Operations
|
||||||
|
|
||||||
|
|
@ -439,21 +361,6 @@
|
||||||
(list :country country :city city :time (get-universal-time)))
|
(list :country country :city city :time (get-universal-time)))
|
||||||
(cons country city)))))))
|
(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 ()
|
(defun collect-geo-stats-from-web-listeners ()
|
||||||
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
|
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
|
||||||
|
|
@ -476,25 +383,12 @@
|
||||||
location-counts)))
|
location-counts)))
|
||||||
|
|
||||||
(defun poll-and-store-stats ()
|
(defun poll-and-store-stats ()
|
||||||
"Single poll iteration: fetch stats and store.
|
"Single poll iteration: fetch listener counts from cl-streamer 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"))
|
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
||||||
(let ((listeners (cl-streamer:get-listener-count mount)))
|
(let ((listeners (cl-streamer:get-listener-count mount)))
|
||||||
(when (and listeners (> listeners 0))
|
(when (and listeners (> listeners 0))
|
||||||
(store-listener-snapshot mount listeners)
|
(store-listener-snapshot mount listeners)
|
||||||
(log:debug "Stored snapshot: ~a = ~a listeners" 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 (uses real IPs from X-Forwarded-For)
|
||||||
(collect-geo-stats-from-web-listeners))
|
(collect-geo-stats-from-web-listeners))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@
|
||||||
(setup-event-listeners)
|
(setup-event-listeners)
|
||||||
(load-playlist-list)
|
(load-playlist-list)
|
||||||
(load-current-queue)
|
(load-current-queue)
|
||||||
(refresh-liquidsoap-status)
|
(refresh-stream-status)
|
||||||
(setup-stats-refresh)
|
(setup-stats-refresh)
|
||||||
(refresh-scheduler-status)
|
(refresh-scheduler-status)
|
||||||
(refresh-track-requests)
|
(refresh-track-requests)
|
||||||
;; Update Liquidsoap status every 10 seconds
|
;; Update stream status every 10 seconds
|
||||||
(set-interval refresh-liquidsoap-status 10000)
|
(set-interval refresh-stream-status 10000)
|
||||||
;; Update scheduler status every 30 seconds
|
;; Update scheduler status every 30 seconds
|
||||||
(set-interval refresh-scheduler-status 30000))))
|
(set-interval refresh-scheduler-status 30000))))
|
||||||
|
|
||||||
|
|
@ -104,24 +104,19 @@
|
||||||
(when refresh-playlists-btn
|
(when refresh-playlists-btn
|
||||||
(ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
|
(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")))
|
(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-skip-btn (ps:chain document (get-element-by-id "ls-skip")))
|
||||||
(ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
|
(ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
|
||||||
(ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
|
(ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
|
||||||
(when ls-refresh-btn
|
(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
|
(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
|
(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
|
(when ls-restart-btn
|
||||||
(ps:chain ls-restart-btn (add-event-listener "click" liquidsoap-restart))))
|
(ps:chain ls-restart-btn (add-event-listener "click" stream-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)))))
|
|
||||||
|
|
||||||
;; Load tracks from API
|
;; Load tracks from API
|
||||||
(defun load-tracks ()
|
(defun load-tracks ()
|
||||||
|
|
@ -697,7 +692,7 @@
|
||||||
(when container
|
(when container
|
||||||
(if (= (ps:@ tracks length) 0)
|
(if (= (ps:@ tracks length) 0)
|
||||||
(setf (ps:@ container inner-h-t-m-l)
|
(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\">"))
|
(let ((html "<div class=\"queue-items\">"))
|
||||||
(ps:chain tracks
|
(ps:chain tracks
|
||||||
(for-each (lambda (track index)
|
(for-each (lambda (track index)
|
||||||
|
|
@ -775,7 +770,7 @@
|
||||||
|
|
||||||
;; Clear stream queue (updated to use new API)
|
;; Clear stream queue (updated to use new API)
|
||||||
(defun clear-stream-queue ()
|
(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))
|
(return))
|
||||||
|
|
||||||
(ps:chain
|
(ps:chain
|
||||||
|
|
@ -793,13 +788,13 @@
|
||||||
(alert "Error clearing queue")))))
|
(alert "Error clearing queue")))))
|
||||||
|
|
||||||
;; ========================================
|
;; ========================================
|
||||||
;; Liquidsoap Control Functions
|
;; Stream Control Functions
|
||||||
;; ========================================
|
;; ========================================
|
||||||
|
|
||||||
;; Refresh Liquidsoap status
|
;; Refresh stream status
|
||||||
(defun refresh-liquidsoap-status ()
|
(defun refresh-stream-status ()
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch "/api/asteroid/liquidsoap/status")
|
(fetch "/api/asteroid/stream/status")
|
||||||
(then (lambda (response) (ps:chain response (json))))
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
|
|
@ -814,28 +809,28 @@
|
||||||
(when metadata-el
|
(when metadata-el
|
||||||
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
|
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error fetching Liquidsoap status:" error))))))
|
(ps:chain console (error "Error fetching stream status:" error))))))
|
||||||
|
|
||||||
;; Skip current track
|
;; Skip current track
|
||||||
(defun liquidsoap-skip ()
|
(defun stream-skip ()
|
||||||
(ps:chain
|
(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 (response) (ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
(if (= (ps:@ data status) "success")
|
(if (= (ps:@ data status) "success")
|
||||||
(progn
|
(progn
|
||||||
(show-toast "⏭️ Track skipped")
|
(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")))))))
|
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error skipping track:" error))
|
(ps:chain console (error "Error skipping track:" error))
|
||||||
(alert "Error skipping track")))))
|
(alert "Error skipping track")))))
|
||||||
|
|
||||||
;; Reload playlist
|
;; Reload playlist
|
||||||
(defun liquidsoap-reload ()
|
(defun stream-reload ()
|
||||||
(ps:chain
|
(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 (response) (ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
|
|
@ -846,44 +841,25 @@
|
||||||
(ps:chain console (error "Error reloading playlist:" error))
|
(ps:chain console (error "Error reloading playlist:" error))
|
||||||
(alert "Error reloading playlist")))))
|
(alert "Error reloading playlist")))))
|
||||||
|
|
||||||
;; Restart Liquidsoap container
|
;; Restart streaming pipeline
|
||||||
(defun liquidsoap-restart ()
|
(defun stream-restart ()
|
||||||
(unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
|
(unless (confirm "Restart the streaming pipeline? This will cause a brief interruption.")
|
||||||
(return))
|
(return))
|
||||||
|
|
||||||
(show-toast "🔄 Restarting Liquidsoap...")
|
(show-toast "🔄 Restarting stream...")
|
||||||
(ps:chain
|
(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 (response) (ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
(if (= (ps:@ data status) "success")
|
(if (= (ps:@ data status) "success")
|
||||||
(progn
|
(progn
|
||||||
(show-toast "✓ Liquidsoap restarting")
|
(show-toast "✓ Stream restarting")
|
||||||
;; Refresh status after a delay to let container restart
|
(set-timeout refresh-stream-status 5000))
|
||||||
(set-timeout refresh-liquidsoap-status 5000))
|
(alert (+ "Error restarting stream: " (or (ps:@ data message) "Unknown error")))))))
|
||||||
(alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
|
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error restarting Liquidsoap:" error))
|
(ps:chain console (error "Error restarting stream:" error))
|
||||||
(alert "Error restarting Liquidsoap")))))
|
(alert "Error restarting stream")))))
|
||||||
|
|
||||||
;; 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")))))
|
|
||||||
|
|
||||||
;; ========================================
|
;; ========================================
|
||||||
;; Listener Statistics
|
;; Listener Statistics
|
||||||
|
|
|
||||||
|
|
@ -33,61 +33,17 @@
|
||||||
(let ((current-hour (local-time:timestamp-hour (local-time:now) :timezone local-time:+utc-zone+)))
|
(let ((current-hour (local-time:timestamp-hour (local-time:now) :timezone local-time:+utc-zone+)))
|
||||||
(get-scheduled-playlist-for-hour current-hour)))
|
(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)
|
(defun load-scheduled-playlist (playlist-name)
|
||||||
"Load a playlist by name and trigger playback.
|
"Load a playlist by name and trigger playback via the Harmony pipeline."
|
||||||
Uses Harmony pipeline when available, falls back to Liquidsoap."
|
|
||||||
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
|
(let ((playlist-path (merge-pathnames playlist-name (get-playlists-directory))))
|
||||||
(if (probe-file playlist-path)
|
(if (probe-file playlist-path)
|
||||||
(progn
|
(progn
|
||||||
(copy-playlist-to-stream-queue playlist-path)
|
(copy-playlist-to-stream-queue playlist-path)
|
||||||
(load-queue-from-m3u-file)
|
(load-queue-from-m3u-file)
|
||||||
(if *harmony-pipeline*
|
|
||||||
;; Use cl-streamer directly
|
|
||||||
(let ((count (harmony-load-playlist playlist-path)))
|
(let ((count (harmony-load-playlist playlist-path)))
|
||||||
(if count
|
(if count
|
||||||
(log:info "Scheduler loaded ~a (~a tracks via Harmony)" playlist-name count)
|
(log:info "Scheduler loaded ~a (~a tracks)" playlist-name count)
|
||||||
(log:error "Scheduler failed to load ~a via Harmony" playlist-name)))
|
(log:error "Scheduler failed to load ~a" 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))))
|
|
||||||
t)
|
t)
|
||||||
(progn
|
(progn
|
||||||
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
|
;;;; 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)
|
(in-package :asteroid)
|
||||||
|
|
||||||
|
|
@ -91,8 +91,8 @@
|
||||||
|
|
||||||
(defun regenerate-stream-playlist ()
|
(defun regenerate-stream-playlist ()
|
||||||
"Regenerate the main stream playlist from the current queue.
|
"Regenerate the main stream playlist from the current queue.
|
||||||
NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u
|
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."
|
This function may be deprecated."
|
||||||
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||||
(asdf:system-source-directory :asteroid))))
|
(asdf:system-source-directory :asteroid))))
|
||||||
(if (null *stream-queue*)
|
(if (null *stream-queue*)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
;;;; stream-harmony.lisp - CL-Streamer / Harmony integration for Asteroid Radio
|
;;;; 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.
|
;;;; Provides the same data interface to frontend-partials and admin APIs.
|
||||||
|
|
||||||
(in-package :asteroid)
|
(in-package :asteroid)
|
||||||
|
|
@ -106,7 +106,14 @@
|
||||||
(and (> (length trimmed) 0) (char= (char trimmed 0) #\#)))
|
(and (> (length trimmed) 0) (char= (char trimmed 0) #\#)))
|
||||||
collect (convert-from-docker-path trimmed)))))
|
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)
|
(defun on-harmony-track-change (pipeline track-info)
|
||||||
"Called by cl-streamer when a track changes.
|
"Called by cl-streamer when a track changes.
|
||||||
|
|
@ -147,13 +154,11 @@
|
||||||
(error () nil))))
|
(error () nil))))
|
||||||
|
|
||||||
;;; ---- Now-Playing Data Source ----
|
;;; ---- Now-Playing Data Source ----
|
||||||
;;; These functions provide the same data that icecast-now-playing returned,
|
;;; These functions provide now-playing data from cl-streamer's pipeline state.
|
||||||
;;; but sourced directly from cl-streamer's pipeline state.
|
|
||||||
|
|
||||||
(defun harmony-now-playing (&optional (mount "asteroid.mp3"))
|
(defun harmony-now-playing (&optional (mount "asteroid.mp3"))
|
||||||
"Get now-playing information from cl-streamer pipeline.
|
"Get now-playing information from cl-streamer pipeline.
|
||||||
Returns an alist compatible with the icecast-now-playing format,
|
Returns an alist with now-playing data, or NIL if the pipeline is not running."
|
||||||
or NIL if the pipeline is not running."
|
|
||||||
(when (and *harmony-pipeline*
|
(when (and *harmony-pipeline*
|
||||||
(cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
|
(cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
|
||||||
(let* ((track-info (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*)
|
(setf (cl-streamer/harmony:pipeline-on-track-change *harmony-pipeline*)
|
||||||
#'on-harmony-track-change)
|
#'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
|
;; Start the audio pipeline
|
||||||
(cl-streamer/harmony:start-pipeline *harmony-pipeline*)
|
(cl-streamer/harmony:start-pipeline *harmony-pipeline*)
|
||||||
|
|
||||||
|
|
@ -237,7 +246,7 @@
|
||||||
(cl-streamer:stop)
|
(cl-streamer:stop)
|
||||||
(log:info "Harmony streaming stopped"))
|
(log:info "Harmony streaming stopped"))
|
||||||
|
|
||||||
;;; ---- Playlist Control (replaces Liquidsoap commands) ----
|
;;; ---- Playlist Control ----
|
||||||
|
|
||||||
(defun harmony-load-playlist (m3u-path &key (skip nil))
|
(defun harmony-load-playlist (m3u-path &key (skip nil))
|
||||||
"Load and start playing an M3U playlist through the Harmony pipeline.
|
"Load and start playing an M3U playlist through the Harmony pipeline.
|
||||||
|
|
@ -247,8 +256,11 @@
|
||||||
(when *harmony-pipeline*
|
(when *harmony-pipeline*
|
||||||
(let ((file-list (m3u-to-file-list m3u-path)))
|
(let ((file-list (m3u-to-file-list m3u-path)))
|
||||||
(when file-list
|
(when file-list
|
||||||
;; Track which playlist is active for state persistence
|
;; Store pending playlist path on pipeline — it will be applied
|
||||||
(setf *current-playlist-path* (pathname m3u-path))
|
;; 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
|
;; Clear any existing queue and load new files
|
||||||
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
|
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
|
||||||
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
|
(cl-streamer/harmony:pipeline-queue-files *harmony-pipeline*
|
||||||
|
|
@ -268,7 +280,7 @@
|
||||||
t))
|
t))
|
||||||
|
|
||||||
(defun harmony-get-status ()
|
(defun harmony-get-status ()
|
||||||
"Get current pipeline status (replaces liquidsoap status)."
|
"Get current pipeline status."
|
||||||
(if *harmony-pipeline*
|
(if *harmony-pipeline*
|
||||||
(let ((track (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
|
(let ((track (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
|
||||||
(listeners (cl-streamer:get-listener-count)))
|
(listeners (cl-streamer:get-listener-count)))
|
||||||
|
|
|
||||||
|
|
@ -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/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://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://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://shirakumo.github.io/harmony/" style="color: #00ff00;">Harmony</a></strong> - Common Lisp audio system</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/cl-mixed/" style="color: #00ff00;">CL-Mixed</a></strong> - Audio mixing and processing</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p style="line-height: 1.6;">
|
<p style="line-height: 1.6;">
|
||||||
By building in Common Lisp, we're doubling down on our technical values and creating features
|
By building in Common Lisp, we're doubling down on our technical values and creating features
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,8 @@
|
||||||
<p class="status-good" data-text="database-status">🟢 Connected</p>
|
<p class="status-good" data-text="database-status">🟢 Connected</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h3>Liquidsoap Status</h3>
|
<h3>Stream Status</h3>
|
||||||
<p class="status-error" data-text="liquidsoap-status">🔴 Not Running</p>
|
<p class="status-good" data-text="stream-status"><3E> 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,7 +122,7 @@
|
||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
<h3>Music Library</h3>
|
<h3>Music Library</h3>
|
||||||
<div class="upload-info">
|
<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>
|
<p><strong>To add music:</strong></p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
|
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
|
||||||
|
|
@ -183,7 +178,7 @@
|
||||||
<!-- Stream Queue Management -->
|
<!-- Stream Queue Management -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>🎵 Stream Queue Management</h2>
|
<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 -->
|
<!-- Playlist Selection -->
|
||||||
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
|
<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>
|
<button id="refresh-playlists-btn" class="btn btn-secondary">🔄 Refresh List</button>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -328,13 +323,13 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liquidsoap Stream Control -->
|
<!-- Stream Control -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>📡 Stream Control (Liquidsoap)</h2>
|
<h2>📡 Stream Control</h2>
|
||||||
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
|
<p>Control the live audio stream via the Harmony pipeline.</p>
|
||||||
|
|
||||||
<!-- Status Display -->
|
<!-- 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 style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||||
<div>
|
<div>
|
||||||
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
|
<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-refresh-status" class="btn btn-secondary">🔄 Refresh Status</button>
|
||||||
<button id="ls-skip" class="btn btn-warning">⏭️ Skip Track</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-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>
|
</div>
|
||||||
|
|
||||||
<p style="font-size: 0.9em; color: #888;">
|
<p style="font-size: 0.9em; color: #888;">
|
||||||
<strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br>
|
<strong>Skip Track:</strong> Crossfade to the next track in the playlist.<br>
|
||||||
<strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br>
|
<strong>Reload Playlist:</strong> Re-read stream-queue.m3u into the pipeline.<br>
|
||||||
<strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption).
|
<strong>Restart Pipeline:</strong> Restart the Harmony streaming pipeline (causes brief stream interruption).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<ul style="line-height: 1.8;">
|
<ul style="line-height: 1.8;">
|
||||||
<li><strong>Status:</strong> 🟢 Live</li>
|
<li><strong>Status:</strong> 🟢 Live</li>
|
||||||
<li><strong>Formats:</strong> AAC 96kbps, MP3 128kbps, MP3 64kbps</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue