Compare commits
No commits in common. "f39abeb8f8f176781789846b1323c59d24278a6d" and "47e6c5da466b8f53b5571638c451d9c6d4d58dbb" have entirely different histories.
f39abeb8f8
...
47e6c5da46
|
|
@ -0,0 +1,37 @@
|
|||
#!/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/")
|
||||
256
asteroid.lisp
256
asteroid.lisp
|
|
@ -443,7 +443,12 @@
|
|||
(let ((count (load-queue-from-m3u-file))
|
||||
(channel-name (get-curated-channel-name)))
|
||||
;; Skip/switch to new playlist
|
||||
(harmony-load-playlist playlist-path)
|
||||
(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)
|
||||
|
|
@ -475,7 +480,7 @@
|
|||
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
|
||||
|
||||
(define-api asteroid/stream/playlists/clear () ()
|
||||
"Clear stream-queue.m3u"
|
||||
"Clear stream-queue.m3u (Liquidsoap will fall back to random)"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((stream-queue-path (get-stream-queue-path)))
|
||||
|
|
@ -487,7 +492,7 @@
|
|||
;; Clear in-memory queue
|
||||
(setf *stream-queue* '())
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue cleared"))))))
|
||||
("message" . "Stream queue cleared - Liquidsoap will use random playback"))))))
|
||||
|
||||
(define-api asteroid/stream/playlists/current () ()
|
||||
"Get current stream-queue.m3u contents with track info"
|
||||
|
|
@ -517,46 +522,135 @@
|
|||
("path" . ,docker-path)))))
|
||||
paths)))))))
|
||||
|
||||
;;; Stream Control APIs
|
||||
;;; Liquidsoap Control APIs
|
||||
;;; Control Liquidsoap via telnet interface on port 1234
|
||||
|
||||
(define-api asteroid/stream/status () ()
|
||||
"Get stream status from Harmony pipeline."
|
||||
(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"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((status (harmony-get-status)))
|
||||
(if *harmony-pipeline*
|
||||
(let ((status (harmony-get-status)))
|
||||
(api-output `(("status" . "success")
|
||||
("backend" . "harmony")
|
||||
("uptime" . "n/a")
|
||||
("metadata" . ,(getf status :current-track))
|
||||
("remaining" . "n/a")
|
||||
("listeners" . ,(getf status :listeners))
|
||||
("queue_length" . ,(getf status :queue-length)))))
|
||||
(let ((uptime (liquidsoap-command "uptime"))
|
||||
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
|
||||
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
|
||||
(api-output `(("status" . "success")
|
||||
("backend" . "liquidsoap")
|
||||
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
|
||||
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
|
||||
("remaining" . ,(format-remaining-time
|
||||
(string-trim '(#\Space #\Newline #\Return) remaining-raw)))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/skip () ()
|
||||
"Skip the current track"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(progn
|
||||
(harmony-skip-track)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track skipped (Harmony)"))))
|
||||
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track skipped")
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/reload () ()
|
||||
"Force playlist reload"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(let* ((playlist-path (get-stream-queue-path))
|
||||
(count (harmony-load-playlist playlist-path)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "Playlist reloaded (~A tracks via Harmony)" count)))))
|
||||
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist reloaded")
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/restart () ()
|
||||
"Restart the streaming backend"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(if *harmony-pipeline*
|
||||
(progn
|
||||
(stop-harmony-streaming)
|
||||
(start-harmony-streaming)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Harmony pipeline restarted"))))
|
||||
(let ((result (uiop:run-program
|
||||
"docker restart asteroid-liquidsoap"
|
||||
:output :string
|
||||
:error-output :string
|
||||
:ignore-error-status t)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Liquidsoap container restarting")
|
||||
("result" . ,result)))))))
|
||||
|
||||
(define-api asteroid/icecast/restart () ()
|
||||
"Restart the Icecast Docker container"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((result (uiop:run-program
|
||||
"docker restart asteroid-icecast"
|
||||
:output :string
|
||||
:error-output :string
|
||||
:ignore-error-status t)))
|
||||
(api-output `(("status" . "success")
|
||||
("backend" . "harmony")
|
||||
("uptime" . "n/a")
|
||||
("metadata" . ,(getf status :current-track))
|
||||
("remaining" . "n/a")
|
||||
("listeners" . ,(getf status :listeners))
|
||||
("queue_length" . ,(getf status :queue-length)))))))
|
||||
|
||||
(define-api asteroid/stream/skip () ()
|
||||
"Skip the current track."
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(harmony-skip-track)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track skipped")))))
|
||||
|
||||
(define-api asteroid/stream/reload () ()
|
||||
"Force playlist reload."
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let* ((playlist-path (get-stream-queue-path))
|
||||
(count (harmony-load-playlist playlist-path)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "Playlist reloaded (~A tracks)" count)))))))
|
||||
|
||||
(define-api asteroid/stream/restart () ()
|
||||
"Restart the streaming pipeline."
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(stop-harmony-streaming)
|
||||
(start-harmony-streaming)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Streaming pipeline restarted")))))
|
||||
("message" . "Icecast container restarting")
|
||||
("result" . ,result))))))
|
||||
|
||||
(defun get-track-by-id (track-id)
|
||||
"Get a track by its ID - handles type mismatches"
|
||||
|
|
@ -915,12 +1009,32 @@
|
|||
(asdf:system-source-directory :asteroid))))))
|
||||
|
||||
;; Status check functions
|
||||
(defun check-stream-status ()
|
||||
"Check if the Harmony streaming pipeline is running."
|
||||
(if (and *harmony-pipeline*
|
||||
(cl-streamer/harmony:pipeline-running-p *harmony-pipeline*))
|
||||
(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*
|
||||
"🟢 Running (cl-streamer)"
|
||||
"🔴 Not Running"))
|
||||
(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"))))
|
||||
|
||||
;; Admin page (requires authentication)
|
||||
(define-page admin #@"/admin" ()
|
||||
|
|
@ -937,7 +1051,8 @@
|
|||
:database-status (handler-case
|
||||
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
|
||||
(error () "🔴 No Database Backend"))
|
||||
:stream-status (check-stream-status)
|
||||
:liquidsoap-status (check-liquidsoap-status)
|
||||
:icecast-status (check-icecast-status)
|
||||
:track-count (format nil "~d" track-count)
|
||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
|
||||
:stream-base-url *stream-base-url*
|
||||
|
|
@ -1267,17 +1382,48 @@
|
|||
("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
("stream-status" . "live"))))
|
||||
|
||||
;; Live stream status (kept as asteroid/icecast-status for frontend API compatibility)
|
||||
;; Live stream status
|
||||
(define-api-with-limit asteroid/icecast-status () ()
|
||||
"Get live stream status from cl-streamer pipeline."
|
||||
"Get live stream status. Uses Harmony pipeline when available, falls back to Icecast."
|
||||
(with-error-handling
|
||||
(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))))))))))
|
||||
(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))))))
|
||||
|
||||
;;; Listener Statistics API Endpoints
|
||||
|
||||
|
|
@ -1421,7 +1567,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
|
||||
;; Start cl-streamer audio pipeline (replaces Icecast + Liquidsoap)
|
||||
(format t "Starting cl-streamer audio pipeline...~%")
|
||||
(handler-case
|
||||
(progn
|
||||
|
|
|
|||
|
|
@ -14,13 +14,10 @@
|
|||
;; 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))
|
||||
|
|
@ -120,12 +117,7 @@
|
|||
(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")
|
||||
(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")))
|
||||
:documentation "Set to T to skip the current track")))
|
||||
|
||||
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
|
||||
(sample-rate 44100) (channels 2))
|
||||
|
|
@ -226,11 +218,9 @@
|
|||
|
||||
(defun ensure-simple-string (s)
|
||||
"Coerce S to a simple-string if it's a string, or return NIL.
|
||||
Coerce first to guarantee simple-string before any string operations,
|
||||
since SBCL's string-trim may require simple-string input."
|
||||
Uses coerce to guarantee SIMPLE-STRING type for downstream consumers."
|
||||
(when (stringp s)
|
||||
(let ((simple (coerce s 'simple-string)))
|
||||
(string-trim '(#\Space #\Nul) simple))))
|
||||
(coerce (string-trim '(#\Space #\Nul) s) 'simple-string)))
|
||||
|
||||
(defun safe-tag (fn audio-file)
|
||||
"Safely read a tag field, coercing to simple-string. Returns NIL on any error."
|
||||
|
|
@ -290,19 +280,12 @@
|
|||
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-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))
|
||||
(let* ((path (pathname file-path))
|
||||
(server (pipeline-harmony-server pipeline))
|
||||
(harmony:*server* server)
|
||||
(tags (read-audio-metadata path))
|
||||
(display-title (format-display-title path title))
|
||||
(track-info (list :file path-string
|
||||
(track-info (list :file (namestring path)
|
||||
:display-title display-title
|
||||
:artist (getf tags :artist)
|
||||
:title (getf tags :title)
|
||||
|
|
@ -354,16 +337,6 @@
|
|||
;; 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)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
:initarg :stream-type
|
||||
:reader error-stream-type
|
||||
:initform nil
|
||||
:documentation "Type of stream (e.g., 'harmony', 'cl-streamer')"))
|
||||
:documentation "Type of stream (e.g., 'icecast', 'liquidsoap')"))
|
||||
(:documentation "Signaled when stream operations fail")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Stream Error~@[ (~a)~]: ~a"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
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"]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# 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"]
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
#!/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,4 +1,40 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<!-- 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>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<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)
|
||||
"Find a track in the database by its title. Returns track ID or nil.
|
||||
Handles 'Artist - Title' format from stream metadata."
|
||||
Handles 'Artist - Title' format from Icecast metadata."
|
||||
(when (and title (not (string= title "Unknown")))
|
||||
(handler-case
|
||||
(with-db
|
||||
|
|
@ -35,15 +35,74 @@
|
|||
(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 the Harmony pipeline.
|
||||
"Get now-playing stats from Harmony pipeline, falling back to Icecast.
|
||||
Returns an alist with :listenurl, :title, :listeners, :track-id, :favorite-count."
|
||||
(harmony-now-playing mount))
|
||||
(or (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)
|
||||
"Get Partial HTML with live now-playing status.
|
||||
Optional MOUNT parameter specifies which stream to get metadata from.
|
||||
Returns partial HTML with current track info."
|
||||
Uses Harmony pipeline when available, falls back to Icecast."
|
||||
(with-error-handling
|
||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||
(now-playing-stats (get-now-playing-stats mount-name)))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
;;;; listener-stats.lisp - Listener Statistics Collection Service
|
||||
;;;; Polls cl-streamer for listener data and stores with GDPR compliance
|
||||
;;;; Polls Icecast for listener data and stores with GDPR compliance
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
;;; Configuration
|
||||
(defvar *stats-polling-interval* 60
|
||||
"Seconds between listener count polls")
|
||||
"Seconds between Icecast polls")
|
||||
|
||||
(defvar *stats-polling-thread* nil
|
||||
"Background thread for polling")
|
||||
|
|
@ -15,6 +15,15 @@
|
|||
(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)")
|
||||
|
||||
|
|
@ -135,7 +144,76 @@
|
|||
(log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
|
||||
nil)))
|
||||
|
||||
;;; Listener Polling
|
||||
;;; 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)))
|
||||
|
||||
;;; Database Operations
|
||||
|
||||
|
|
@ -361,6 +439,21 @@
|
|||
(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)"
|
||||
|
|
@ -383,12 +476,25 @@
|
|||
location-counts)))
|
||||
|
||||
(defun poll-and-store-stats ()
|
||||
"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))))
|
||||
"Single poll iteration: fetch stats and store.
|
||||
Uses cl-streamer listener counts when Harmony is running, falls back to Icecast."
|
||||
(if *harmony-pipeline*
|
||||
;; Get listener counts directly from cl-streamer
|
||||
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
||||
(let ((listeners (cl-streamer:get-listener-count mount)))
|
||||
(when (and listeners (> listeners 0))
|
||||
(store-listener-snapshot mount listeners)
|
||||
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
|
||||
;; Fallback: poll Icecast
|
||||
(let ((stats (fetch-icecast-stats)))
|
||||
(when stats
|
||||
(let ((sources (parse-icecast-sources stats)))
|
||||
(dolist (source sources)
|
||||
(let ((mount (getf source :mount))
|
||||
(listeners (getf source :listeners)))
|
||||
(when mount
|
||||
(store-listener-snapshot mount listeners)
|
||||
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
|
||||
;; Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)
|
||||
(collect-geo-stats-from-web-listeners))
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@
|
|||
(setup-event-listeners)
|
||||
(load-playlist-list)
|
||||
(load-current-queue)
|
||||
(refresh-stream-status)
|
||||
(refresh-liquidsoap-status)
|
||||
(setup-stats-refresh)
|
||||
(refresh-scheduler-status)
|
||||
(refresh-track-requests)
|
||||
;; Update stream status every 10 seconds
|
||||
(set-interval refresh-stream-status 10000)
|
||||
;; Update Liquidsoap status every 10 seconds
|
||||
(set-interval refresh-liquidsoap-status 10000)
|
||||
;; Update scheduler status every 30 seconds
|
||||
(set-interval refresh-scheduler-status 30000))))
|
||||
|
||||
|
|
@ -104,19 +104,24 @@
|
|||
(when refresh-playlists-btn
|
||||
(ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
|
||||
|
||||
;; Stream controls
|
||||
;; Liquidsoap 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-stream-status)))
|
||||
(ps:chain ls-refresh-btn (add-event-listener "click" refresh-liquidsoap-status)))
|
||||
(when ls-skip-btn
|
||||
(ps:chain ls-skip-btn (add-event-listener "click" stream-skip)))
|
||||
(ps:chain ls-skip-btn (add-event-listener "click" liquidsoap-skip)))
|
||||
(when ls-reload-btn
|
||||
(ps:chain ls-reload-btn (add-event-listener "click" stream-reload)))
|
||||
(ps:chain ls-reload-btn (add-event-listener "click" liquidsoap-reload)))
|
||||
(when ls-restart-btn
|
||||
(ps:chain ls-restart-btn (add-event-listener "click" stream-restart)))))
|
||||
(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)))))
|
||||
|
||||
;; Load tracks from API
|
||||
(defun load-tracks ()
|
||||
|
|
@ -692,7 +697,7 @@
|
|||
(when container
|
||||
(if (= (ps:@ tracks length) 0)
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"empty-state\">Queue is empty.</div>")
|
||||
"<div class=\"empty-state\">Queue is empty. Liquidsoap will use random playback from the music library.</div>")
|
||||
(let ((html "<div class=\"queue-items\">"))
|
||||
(ps:chain tracks
|
||||
(for-each (lambda (track index)
|
||||
|
|
@ -770,7 +775,7 @@
|
|||
|
||||
;; Clear stream queue (updated to use new API)
|
||||
(defun clear-stream-queue ()
|
||||
(unless (confirm "Clear the stream queue?")
|
||||
(unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
|
|
@ -788,13 +793,13 @@
|
|||
(alert "Error clearing queue")))))
|
||||
|
||||
;; ========================================
|
||||
;; Stream Control Functions
|
||||
;; Liquidsoap Control Functions
|
||||
;; ========================================
|
||||
|
||||
;; Refresh stream status
|
||||
(defun refresh-stream-status ()
|
||||
;; Refresh Liquidsoap status
|
||||
(defun refresh-liquidsoap-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/status")
|
||||
(fetch "/api/asteroid/liquidsoap/status")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
|
|
@ -809,28 +814,28 @@
|
|||
(when metadata-el
|
||||
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching stream status:" error))))))
|
||||
(ps:chain console (error "Error fetching Liquidsoap status:" error))))))
|
||||
|
||||
;; Skip current track
|
||||
(defun stream-skip ()
|
||||
(defun liquidsoap-skip ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/skip" (ps:create :method "POST"))
|
||||
(fetch "/api/asteroid/liquidsoap/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-stream-status 1000))
|
||||
(set-timeout refresh-liquidsoap-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 stream-reload ()
|
||||
(defun liquidsoap-reload ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/reload" (ps:create :method "POST"))
|
||||
(fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
|
|
@ -841,25 +846,44 @@
|
|||
(ps:chain console (error "Error reloading playlist:" error))
|
||||
(alert "Error reloading playlist")))))
|
||||
|
||||
;; Restart streaming pipeline
|
||||
(defun stream-restart ()
|
||||
(unless (confirm "Restart the streaming pipeline? This will cause a brief interruption.")
|
||||
;; Restart Liquidsoap container
|
||||
(defun liquidsoap-restart ()
|
||||
(unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
|
||||
(return))
|
||||
|
||||
(show-toast "🔄 Restarting stream...")
|
||||
(show-toast "🔄 Restarting Liquidsoap...")
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/restart" (ps:create :method "POST"))
|
||||
(fetch "/api/asteroid/liquidsoap/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 "✓ Stream restarting")
|
||||
(set-timeout refresh-stream-status 5000))
|
||||
(alert (+ "Error restarting stream: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(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")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error restarting stream:" error))
|
||||
(alert "Error restarting stream")))))
|
||||
(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")))))
|
||||
|
||||
;; ========================================
|
||||
;; Listener Statistics
|
||||
|
|
|
|||
|
|
@ -33,17 +33,61 @@
|
|||
(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 via the Harmony pipeline."
|
||||
"Load a playlist by name and trigger playback.
|
||||
Uses Harmony pipeline when available, falls back to Liquidsoap."
|
||||
(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)
|
||||
(let ((count (harmony-load-playlist playlist-path)))
|
||||
(if count
|
||||
(log:info "Scheduler loaded ~a (~a tracks)" playlist-name count)
|
||||
(log:error "Scheduler failed to load ~a" playlist-name)))
|
||||
(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))))
|
||||
t)
|
||||
(progn
|
||||
(log:error "Scheduler playlist not found: ~a" playlist-name)
|
||||
|
|
@ -312,21 +356,14 @@
|
|||
;;; This ensures the scheduler starts after the server is fully initialized
|
||||
|
||||
(define-trigger db:connected ()
|
||||
"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)."
|
||||
"Start the playlist scheduler after database connection is established"
|
||||
(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"))))
|
||||
(let ((current-playlist (get-current-scheduled-playlist)))
|
||||
(when current-playlist
|
||||
(load-scheduled-playlist current-playlist)))
|
||||
(log:info "Playlist scheduler started"))
|
||||
(error (e)
|
||||
(log:error "Scheduler failed to start: ~a" e))))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
|
||||
|
||||
(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.
|
||||
This function may be deprecated."
|
||||
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."
|
||||
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(if (null *stream-queue*)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
;;;; stream-harmony.lisp - CL-Streamer / Harmony integration for Asteroid Radio
|
||||
;;;; In-process audio streaming via Harmony + cl-streamer.
|
||||
;;;; Replaces the Icecast + Liquidsoap stack with in-process audio streaming.
|
||||
;;;; Provides the same data interface to frontend-partials and admin APIs.
|
||||
|
||||
(in-package :asteroid)
|
||||
|
|
@ -27,10 +27,6 @@
|
|||
(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."
|
||||
|
|
@ -76,7 +72,6 @@
|
|||
(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
|
||||
|
|
@ -111,14 +106,7 @@
|
|||
(and (> (length trimmed) 0) (char= (char trimmed 0) #\#)))
|
||||
collect (convert-from-docker-path trimmed)))))
|
||||
|
||||
;;; ---- 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)))
|
||||
;;; ---- Track Change Callback ----
|
||||
|
||||
(defun on-harmony-track-change (pipeline track-info)
|
||||
"Called by cl-streamer when a track changes.
|
||||
|
|
@ -159,11 +147,13 @@
|
|||
(error () nil))))
|
||||
|
||||
;;; ---- Now-Playing Data Source ----
|
||||
;;; These functions provide now-playing data from cl-streamer's pipeline state.
|
||||
;;; These functions provide the same data that icecast-now-playing returned,
|
||||
;;; but sourced directly 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 with now-playing data, or NIL if the pipeline is not running."
|
||||
Returns an alist compatible with the icecast-now-playing format,
|
||||
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*))
|
||||
|
|
@ -227,10 +217,6 @@
|
|||
(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*)
|
||||
|
||||
|
|
@ -251,7 +237,7 @@
|
|||
(cl-streamer:stop)
|
||||
(log:info "Harmony streaming stopped"))
|
||||
|
||||
;;; ---- Playlist Control ----
|
||||
;;; ---- Playlist Control (replaces Liquidsoap commands) ----
|
||||
|
||||
(defun harmony-load-playlist (m3u-path &key (skip nil))
|
||||
"Load and start playing an M3U playlist through the Harmony pipeline.
|
||||
|
|
@ -261,11 +247,8 @@
|
|||
(when *harmony-pipeline*
|
||||
(let ((file-list (m3u-to-file-list m3u-path)))
|
||||
(when file-list
|
||||
;; 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))
|
||||
;; Track which playlist is active for state persistence
|
||||
(setf *current-playlist-path* (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*
|
||||
|
|
@ -285,7 +268,7 @@
|
|||
t))
|
||||
|
||||
(defun harmony-get-status ()
|
||||
"Get current pipeline status."
|
||||
"Get current pipeline status (replaces liquidsoap status)."
|
||||
(if *harmony-pipeline*
|
||||
(let ((track (cl-streamer/harmony:pipeline-current-track *harmony-pipeline*))
|
||||
(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/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://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>
|
||||
<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>
|
||||
</ul>
|
||||
<p style="line-height: 1.6;">
|
||||
By building in Common Lisp, we're doubling down on our technical values and creating features
|
||||
|
|
|
|||
|
|
@ -26,8 +26,13 @@
|
|||
<p class="status-good" data-text="database-status">🟢 Connected</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<h3>Stream Status</h3>
|
||||
<p class="status-good" data-text="stream-status"><3E> Running</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +127,7 @@
|
|||
<div class="upload-section">
|
||||
<h3>Music Library</h3>
|
||||
<div class="upload-info">
|
||||
<p>The music library is loaded from your local filesystem.</p>
|
||||
<p>The music library is mounted from your local filesystem into the Liquidsoap container.</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>
|
||||
|
|
@ -178,7 +183,7 @@
|
|||
<!-- Stream Queue Management -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Stream Queue Management</h2>
|
||||
<p>Manage the live stream playback queue.</p>
|
||||
<p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p>
|
||||
|
||||
<!-- Playlist Selection -->
|
||||
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
|
||||
|
|
@ -191,7 +196,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 queue it for playback via the Harmony pipeline.
|
||||
Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -323,13 +328,13 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stream Control -->
|
||||
<!-- Liquidsoap Stream Control -->
|
||||
<div class="admin-section">
|
||||
<h2>📡 Stream Control</h2>
|
||||
<p>Control the live audio stream via the Harmony pipeline.</p>
|
||||
<h2>📡 Stream Control (Liquidsoap)</h2>
|
||||
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
|
||||
|
||||
<!-- Status Display -->
|
||||
<div id="stream-status-detail" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
|
||||
<div id="liquidsoap-status" 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>
|
||||
|
|
@ -348,13 +353,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 Pipeline</button>
|
||||
<button id="ls-restart" class="btn btn-danger">🔄 Restart Container</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.9em; color: #888;">
|
||||
<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).
|
||||
<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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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> CL-Streamer / Harmony</li>
|
||||
<li><strong>Server:</strong> Icecast</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue