Compare commits
No commits in common. "04a874ceb5d0ea0fa11d1a94aad718fbc6898112" and "3e6b4963402a9bfc7277113acb8c9f1c93178be9" have entirely different histories.
04a874ceb5
...
3e6b496340
|
|
@ -553,10 +553,8 @@
|
||||||
"Restart the streaming pipeline."
|
"Restart the streaming pipeline."
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(stop-shuffle-streaming)
|
|
||||||
(stop-harmony-streaming)
|
(stop-harmony-streaming)
|
||||||
(start-harmony-streaming)
|
(start-harmony-streaming)
|
||||||
(start-shuffle-streaming)
|
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Streaming pipeline restarted")))))
|
("message" . "Streaming pipeline restarted")))))
|
||||||
|
|
||||||
|
|
@ -1432,9 +1430,7 @@
|
||||||
(with-error-handling
|
(with-error-handling
|
||||||
(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 (if *harmony-pipeline*
|
(listeners (or (cl-streamer:get-listener-count) 0)))
|
||||||
(or (cl-streamer:pipeline-listener-count *harmony-pipeline*) 0)
|
|
||||||
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)
|
||||||
|
|
@ -1605,15 +1601,7 @@
|
||||||
(length resumed-list)
|
(length resumed-list)
|
||||||
(if playlist-path (file-namestring playlist-path) "stream-queue.m3u"))))
|
(if playlist-path (file-namestring playlist-path) "stream-queue.m3u"))))
|
||||||
(format t "📡 Stream: ~a/asteroid.mp3~%" *stream-base-url*)
|
(format t "📡 Stream: ~a/asteroid.mp3~%" *stream-base-url*)
|
||||||
(format t "📡 Stream: ~a/asteroid.aac~%" *stream-base-url*)
|
(format t "📡 Stream: ~a/asteroid.aac~%" *stream-base-url*))
|
||||||
;; Start shuffle stream (shares the same HTTP server)
|
|
||||||
(handler-case
|
|
||||||
(progn
|
|
||||||
(start-shuffle-streaming)
|
|
||||||
(format t "📡 Shuffle: ~a/shuffle.mp3~%" *stream-base-url*)
|
|
||||||
(format t "📡 Shuffle: ~a/shuffle.aac~%" *stream-base-url*))
|
|
||||||
(error (e)
|
|
||||||
(format t "⚠️ Could not start shuffle stream: ~a~%" e))))
|
|
||||||
(error (e)
|
(error (e)
|
||||||
(format t "⚠️ Could not start streaming: ~a~%" e)
|
(format t "⚠️ Could not start streaming: ~a~%" e)
|
||||||
(format t " (Web server will run without streaming)~%")))
|
(format t " (Web server will run without streaming)~%")))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
#+TITLE: CL-Streamer Standalone Library Refactor
|
#+TITLE: CL-Streamer Standalone Library Refactor
|
||||||
#+AUTHOR: Glenn Thompson
|
#+AUTHOR: Glenn Thompson
|
||||||
#+DATE: 2026-03-07
|
#+DATE: 2026-03-07
|
||||||
#+UPDATED: 2026-03-08
|
|
||||||
#+DESCRIPTION: Plan for extracting cl-streamer into a standalone, reusable Common Lisp streaming library
|
#+DESCRIPTION: Plan for extracting cl-streamer into a standalone, reusable Common Lisp streaming library
|
||||||
|
|
||||||
* Overview
|
* Overview
|
||||||
|
|
@ -14,21 +13,16 @@ and the application layer communicating through well-defined protocols.
|
||||||
The goal is a library that any CL application could use to stream audio over HTTP,
|
The goal is a library that any CL application could use to stream audio over HTTP,
|
||||||
without any Asteroid-specific knowledge baked in.
|
without any Asteroid-specific knowledge baked in.
|
||||||
|
|
||||||
** Current State (Updated 2026-03-08)
|
** Current State
|
||||||
|
|
||||||
=cl-streamer= is now a standalone git repository (https://github.com/glenneth1/cl-streamer)
|
=cl-streamer= already lives in its own directory (=cl-streamer/=) with its own ASDF
|
||||||
loaded as a git submodule. It has a clean CLOS protocol layer, a declarative pipeline DSL,
|
system definitions and packages. However, it has implicit coupling to Asteroid through:
|
||||||
a hook system for callbacks, and iolib-based networking. All Asteroid-specific coupling
|
|
||||||
has been moved to the glue layer (=stream-harmony.lisp=).
|
|
||||||
|
|
||||||
Completed milestones:
|
- The glue layer (=stream-harmony.lisp=) lives in the =:asteroid= package
|
||||||
|
- Encoder creation and lifecycle managed by Asteroid, not cl-streamer
|
||||||
- *Phase 1* (c79cebc): CLOS protocol layer — generic functions in =protocol.lisp=
|
- Mount configuration hardcoded in Asteroid's startup
|
||||||
- *Phase 2* (b7266e3): Clean boundaries — =make-pipeline= DSL, =pipeline-add-hook=, encoder lifecycle owned by pipeline
|
- Playlist loading, scheduling, and resume logic all in Asteroid
|
||||||
- *Phase 3* (2e86dc4): iolib refactor — SO_KEEPALIVE, TCP_NODELAY, SO_SNDTIMEO
|
- DJ session control reaches directly into Harmony internals
|
||||||
- *Phase 4* (8d9d2b3): Extracted to standalone repo, loaded as git submodule
|
|
||||||
- *Step 1* (cl-streamer c0f9c0e, asteroid 3e6b496): Eliminated =*server*= global, DJ protocol generics
|
|
||||||
- *Step 2* (asteroid 5efa493): Shuffle stream as second pipeline — shared server, separate mounts, full library random
|
|
||||||
|
|
||||||
** Architecture Vision (per Fade)
|
** Architecture Vision (per Fade)
|
||||||
|
|
||||||
|
|
@ -39,7 +33,7 @@ and a queue manager standing between the asteroid application and the stream dae
|
||||||
This allows scaling horizontally in an unintrusive way."
|
This allows scaling horizontally in an unintrusive way."
|
||||||
#+end_quote
|
#+end_quote
|
||||||
|
|
||||||
Current architecture (implemented):
|
Target architecture:
|
||||||
|
|
||||||
#+begin_example
|
#+begin_example
|
||||||
┌─────────────────────────┐ ┌──────────────────────────────┐
|
┌─────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
|
@ -49,23 +43,22 @@ Current architecture (implemented):
|
||||||
│ DJ Session ────────────┼──>──┤ (generic functions / CLOS) │
|
│ DJ Session ────────────┼──>──┤ (generic functions / CLOS) │
|
||||||
│ Admin APIs ────────────┼──>──┤ │
|
│ Admin APIs ────────────┼──>──┤ │
|
||||||
│ Track Metadata ────────┼──>──┤ Stream Server (iolib) │
|
│ Track Metadata ────────┼──>──┤ Stream Server (iolib) │
|
||||||
│ Shuffle Stream ────────┼──>──┤ Encoders (MP3, AAC) │
|
│ │ │ Encoders (MP3, AAC) │
|
||||||
│ Frontend/ParenScript │ │ Broadcast Buffer │
|
│ Frontend/ParenScript │ │ Broadcast Buffer │
|
||||||
│ │ │ Harmony Backend │
|
│ │ │ Harmony Backend │
|
||||||
│ Rate Limiting │ │ ICY Protocol │
|
│ Rate Limiting │ │ ICY Protocol │
|
||||||
│ User Management │ │ Hook System │
|
│ User Management │ │ │
|
||||||
└─────────────────────────┘ └──────────────────────────────┘
|
└─────────────────────────┘ └──────────────────────────────┘
|
||||||
Application Library (git submodule)
|
Application Library / Daemon
|
||||||
#+end_example
|
#+end_example
|
||||||
|
|
||||||
* Phase 1: Define the Protocol (CLOS Generic Functions) — DONE ✓
|
* Phase 1: Define the Protocol (CLOS Generic Functions)
|
||||||
|
|
||||||
The protocol layer decouples Asteroid from cl-streamer internals. Implemented in
|
The key deliverable is a protocol layer that decouples Asteroid from cl-streamer internals.
|
||||||
=cl-streamer/protocol.lisp= (c79cebc).
|
|
||||||
|
|
||||||
** 1.1 Streaming Protocol Classes
|
** 1.1 Streaming Protocol Classes
|
||||||
|
|
||||||
Protocol classes that cl-streamer exports and Asteroid programs against:
|
Define protocol classes that cl-streamer exports and Asteroid programs against:
|
||||||
|
|
||||||
#+begin_src lisp
|
#+begin_src lisp
|
||||||
;; Protocol classes — no implementation, just interface
|
;; Protocol classes — no implementation, just interface
|
||||||
|
|
@ -81,7 +74,7 @@ Protocol classes that cl-streamer exports and Asteroid programs against:
|
||||||
|
|
||||||
** 1.2 Generic Functions (the Protocol)
|
** 1.2 Generic Functions (the Protocol)
|
||||||
|
|
||||||
Implemented in =cl-streamer/protocol.lisp=, exported from =cl-streamer= package:
|
These replace the current direct function calls:
|
||||||
|
|
||||||
#+begin_src lisp
|
#+begin_src lisp
|
||||||
;;; Pipeline lifecycle
|
;;; Pipeline lifecycle
|
||||||
|
|
@ -104,14 +97,6 @@ Implemented in =cl-streamer/protocol.lisp=, exported from =cl-streamer= package:
|
||||||
;;; Metadata
|
;;; Metadata
|
||||||
(defgeneric pipeline-update-metadata (pipeline mount title &optional url))
|
(defgeneric pipeline-update-metadata (pipeline mount title &optional url))
|
||||||
|
|
||||||
;;; DJ voice control (added in Step 1, c0f9c0e)
|
|
||||||
(defgeneric pipeline-play-voice (pipeline file-path &key))
|
|
||||||
(defgeneric pipeline-stop-voice (pipeline voice))
|
|
||||||
(defgeneric pipeline-stop-all-voices (pipeline))
|
|
||||||
(defgeneric pipeline-volume-ramp (pipeline voice target-volume duration))
|
|
||||||
(defgeneric pipeline-read-metadata (pipeline file-path))
|
|
||||||
(defgeneric pipeline-format-title (pipeline file-path))
|
|
||||||
|
|
||||||
;;; Encoder protocol
|
;;; Encoder protocol
|
||||||
(defgeneric encoder-encode (encoder pcm-data &key start end))
|
(defgeneric encoder-encode (encoder pcm-data &key start end))
|
||||||
(defgeneric encoder-flush (encoder))
|
(defgeneric encoder-flush (encoder))
|
||||||
|
|
@ -127,7 +112,7 @@ Implemented in =cl-streamer/protocol.lisp=, exported from =cl-streamer= package:
|
||||||
|
|
||||||
** 1.3 Callback Protocol
|
** 1.3 Callback Protocol
|
||||||
|
|
||||||
Hook system implemented — replaces the old =setf= slot approach:
|
Replace the current =setf= slot approach with a proper callback/hook system:
|
||||||
|
|
||||||
#+begin_src lisp
|
#+begin_src lisp
|
||||||
(defgeneric pipeline-add-hook (pipeline event function))
|
(defgeneric pipeline-add-hook (pipeline event function))
|
||||||
|
|
@ -136,235 +121,151 @@ Hook system implemented — replaces the old =setf= slot approach:
|
||||||
;; Events: :track-change, :playlist-change, :pipeline-start, :pipeline-stop
|
;; Events: :track-change, :playlist-change, :pipeline-start, :pipeline-stop
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
** 1.4 Files Created/Modified
|
** 1.4 Files to Create/Modify
|
||||||
|
|
||||||
| File | Action | Description |
|
| File | Action | Description |
|
||||||
|-----------------------------------+----------+------------------------------------------|
|
|----------------------------+--------+------------------------------------------|
|
||||||
| =cl-streamer/protocol.lisp= | CREATED | Protocol classes and generic functions |
|
| =cl-streamer/protocol.lisp= | NEW | Protocol classes and generic functions |
|
||||||
| =cl-streamer/package.lisp= | MODIFIED | Export protocol symbols |
|
| =cl-streamer/package.lisp= | MODIFY | Export protocol symbols |
|
||||||
| =cl-streamer/cl-streamer.asd= | MODIFIED | Add protocol.lisp to all systems |
|
| =cl-streamer/cl-streamer.asd= | MODIFY | Add protocol.lisp to all systems |
|
||||||
| =cl-streamer/harmony-backend.lisp= | MODIFIED | Implement protocol for Harmony backend |
|
| =cl-streamer/harmony-backend.lisp= | MODIFY | Implement protocol for Harmony backend |
|
||||||
|
|
||||||
* Phase 2: Clean Package Boundaries — DONE ✓
|
* Phase 2: Clean Package Boundaries
|
||||||
|
|
||||||
Implemented in b7266e3, with further refinement in Step 1 (c0f9c0e).
|
** 2.1 Move Asteroid-Specific Code Out of cl-streamer
|
||||||
|
|
||||||
** 2.1 Asteroid-Specific Code Separation — DONE ✓
|
Currently cl-streamer is clean. The coupling is in Asteroid's =stream-harmony.lisp=
|
||||||
|
which directly references cl-streamer internals. This file should become a thin adapter:
|
||||||
|
|
||||||
=stream-harmony.lisp= is now a thin adapter. All encoder/mount/server wiring is inside
|
| Current (stream-harmony.lisp) | Target |
|
||||||
cl-streamer's =make-pipeline= DSL.
|
|
||||||
|
|
||||||
| Before | After (current) |
|
|
||||||
|-------------------------------------+-------------------------------------------|
|
|-------------------------------------+-------------------------------------------|
|
||||||
| Creates encoders explicitly | =make-pipeline :outputs= handles it |
|
| Creates encoders explicitly | Calls =(make-pipeline :mp3 :aac)= |
|
||||||
| Manages mount points manually | Pipeline auto-creates from output specs |
|
| Manages mount points manually | Pipeline auto-creates from config |
|
||||||
| Stores encoder instances as globals | Pipeline owns encoder lifecycle |
|
| Stores encoder instances as globals | Pipeline owns encoder lifecycle |
|
||||||
| Track-change callback via setf | Uses =pipeline-add-hook= |
|
| Track-change callback via setf | Uses =pipeline-add-hook= |
|
||||||
| Docker path conversion | Stays in Asteroid (app-specific) |
|
| Docker path conversion | Stays in Asteroid (app-specific) |
|
||||||
| Playback state persistence | Stays in Asteroid (app-specific) |
|
| Playback state persistence | Stays in Asteroid (app-specific) |
|
||||||
| Playlist scheduling | Stays in Asteroid (app-specific) |
|
| Playlist scheduling | Stays in Asteroid (app-specific) |
|
||||||
|
|
||||||
** 2.2 Declarative Pipeline Configuration (DSL) — DONE ✓
|
** 2.2 Declarative Pipeline Configuration (DSL)
|
||||||
|
|
||||||
The declarative =make-pipeline= uses keyword arguments rather than the originally
|
Replace the imperative setup in =start-harmony-streaming= with a declarative form:
|
||||||
proposed quoted-plist form. The result is functionally equivalent — both describe
|
|
||||||
/what/ you want and let =make-pipeline= handle the wiring.
|
|
||||||
|
|
||||||
*Why keyword args instead of a quoted plist:*
|
|
||||||
|
|
||||||
1. *Idiomatic* — Harmony and cl-mixed use keyword-heavy APIs throughout.
|
|
||||||
Keyword args follow the same convention.
|
|
||||||
2. *Compile-time checking* — typos like =:fromat= are caught immediately;
|
|
||||||
a quoted plist silently carries bad keys until runtime parsing catches them.
|
|
||||||
3. *No parser needed* — CLOS =&key= destructuring handles dispatch for free.
|
|
||||||
A quoted plist requires a DSL interpreter to destructure, validate, and dispatch.
|
|
||||||
4. *Composability* — the =:server= parameter naturally accepts a live server
|
|
||||||
object for pipeline sharing. A quoted plist would need to special-case
|
|
||||||
=:server= to accept either a spec /or/ an existing object.
|
|
||||||
5. *Extensible* — new options (=:crossfade-duration=, =:loop-queue=) are just
|
|
||||||
additional =&key= parameters. No parser changes required.
|
|
||||||
|
|
||||||
Asteroid calls this at startup:
|
|
||||||
|
|
||||||
#+begin_src lisp
|
#+begin_src lisp
|
||||||
;; Curated stream pipeline (start-harmony-streaming)
|
;; Target API — Asteroid calls this at startup
|
||||||
(setf *harmony-pipeline*
|
(setf *harmony-pipeline*
|
||||||
(cl-streamer/harmony:make-pipeline
|
(cl-streamer:make-pipeline
|
||||||
:port 8000
|
'(:input (:harmony :channels 2 :samplerate 44100)
|
||||||
:outputs (list (list :format :mp3
|
:outputs ((:mp3 :mount "/asteroid.mp3" :bitrate 128
|
||||||
:mount "/asteroid.mp3"
|
:content-type "audio/mpeg" :name "Asteroid Radio MP3")
|
||||||
:bitrate 128
|
(:aac :mount "/asteroid.aac" :bitrate 128
|
||||||
:name "Asteroid Radio MP3")
|
:content-type "audio/aac" :name "Asteroid Radio AAC"))
|
||||||
(list :format :aac
|
:server (:port 8000))))
|
||||||
:mount "/asteroid.aac"
|
|
||||||
:bitrate 96
|
|
||||||
:name "Asteroid Radio AAC"))))
|
|
||||||
|
|
||||||
;; Shuffle stream pipeline (start-shuffle-streaming)
|
|
||||||
;; Shares the curated pipeline's server via :server parameter
|
|
||||||
(setf *shuffle-pipeline*
|
|
||||||
(cl-streamer/harmony:make-pipeline
|
|
||||||
:server (cl-streamer/harmony:pipeline-server *harmony-pipeline*)
|
|
||||||
:outputs (list (list :format :mp3
|
|
||||||
:mount "/shuffle.mp3"
|
|
||||||
:bitrate 128
|
|
||||||
:name "Asteroid Radio Shuffle MP3")
|
|
||||||
(list :format :aac
|
|
||||||
:mount "/shuffle.aac"
|
|
||||||
:bitrate 128
|
|
||||||
:name "Asteroid Radio Shuffle AAC"))))
|
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
The DSL handles: encoder creation, mount registration, drain wiring, and server
|
This keeps the pipeline definition in Asteroid but all the wiring inside cl-streamer.
|
||||||
ownership tracking. Multiple pipelines can share a server via =:server=.
|
|
||||||
|
|
||||||
** 2.3 Remove Global State — DONE ✓
|
** 2.3 Remove Global State
|
||||||
|
|
||||||
=*server*= global eliminated (Step 1, c0f9c0e). All state is pipeline-scoped.
|
Current globals in =cl-streamer.lisp= (=*server*=, =*default-port*=) should move to
|
||||||
Multiple pipelines work simultaneously (curated + shuffle, verified in Step 2).
|
pipeline-scoped state. Multiple pipelines should be possible (needed for shuffle channel).
|
||||||
|
|
||||||
* Phase 3: Refactor Stream Server to iolib — DONE ✓
|
* Phase 3: Refactor Stream Server to iolib
|
||||||
|
|
||||||
Implemented in 2e86dc4. Replaced =usocket= with =iolib=.
|
Per Fade's recommendation, replace =sb-bsd-sockets= / =usocket= with =iolib= for:
|
||||||
|
|
||||||
** 3.1 What Changed
|
- Nonblocking I/O with event loop (epoll/kqueue)
|
||||||
|
- Better stale socket detection (SO_KEEPALIVE, TCP_NODELAY)
|
||||||
|
- Portability across CL implementations
|
||||||
|
- Cleaner connection lifecycle management
|
||||||
|
|
||||||
| File | Change |
|
** 3.1 Changes Required
|
||||||
|-----------------------------------+----------------------------------------------|
|
|
||||||
| =cl-streamer/stream-server.lisp= | Rewrote socket handling to use iolib |
|
|
||||||
| =cl-streamer/cl-streamer.asd= | Replaced =usocket= dependency with =iolib= |
|
|
||||||
| =cl-streamer/package.lisp= | Updated exports |
|
|
||||||
|
|
||||||
** 3.2 iolib Features in Use
|
| File | Change |
|
||||||
|
|-------------------------------+--------------------------------------------------|
|
||||||
|
| =cl-streamer/stream-server.lisp= | Rewrite socket handling to use iolib |
|
||||||
|
| =cl-streamer/cl-streamer.asd= | Replace =usocket= dependency with =iolib= |
|
||||||
|
| =cl-streamer/package.lisp= | Update exports if socket API changes |
|
||||||
|
|
||||||
|
** 3.2 Key iolib Features to Use
|
||||||
|
|
||||||
- =iolib:make-socket= with =:connect :passive= for the listener
|
- =iolib:make-socket= with =:connect :passive= for the listener
|
||||||
- =iolib:accept-connection= for new clients
|
- =iolib:accept-connection= for new clients
|
||||||
- Per-client threads with iolib sockets
|
- =iomux:event-dispatch= or per-client threads with iolib sockets
|
||||||
- =iolib:socket-option= for =SO_KEEPALIVE=, =TCP_NODELAY=
|
- =iolib:socket-option= for =SO_KEEPALIVE=, =TCP_NODELAY=
|
||||||
- =SO_SNDTIMEO= for write timeouts to detect stale clients
|
- Proper =:timeout= on write operations to detect stale clients
|
||||||
|
|
||||||
** 3.3 Deployment Note
|
** 3.3 Risk Mitigation
|
||||||
|
|
||||||
- iolib requires =libfixposix= — must be installed on VPS before deployment
|
- iolib requires =libfixposix= — ensure it's available on VPS
|
||||||
|
- Test with multiple concurrent listeners
|
||||||
|
- Benchmark against current implementation (should be equivalent or better)
|
||||||
|
|
||||||
* Phase 4: Separate Repository — DONE ✓
|
* Phase 4: Separate Repository (Future)
|
||||||
|
|
||||||
Extracted in 8d9d2b3. cl-streamer is now at https://github.com/glenneth1/cl-streamer
|
Once cl-streamer is fully standalone, it could be extracted to its own git repo
|
||||||
(default branch: =master=). Loaded into Asteroid as a git submodule.
|
and loaded via Quicklisp/Ultralisp. This is optional and depends on whether
|
||||||
|
other projects want to use it.
|
||||||
|
|
||||||
** 4.1 What Stays in Asteroid
|
** 4.1 What Stays in Asteroid
|
||||||
|
|
||||||
- =stream-harmony.lisp= — glue layer / adapter (curated + shuffle pipelines)
|
- =stream-harmony.lisp= — glue layer / adapter
|
||||||
- =stream-control.lisp= — queue management, playlist generation
|
- =stream-control.lisp= — queue management, playlist generation
|
||||||
- =playlist-scheduler.lisp= — cron-based playlist switching
|
- =playlist-scheduler.lisp= — cron-based playlist switching
|
||||||
- =dj-session.lisp= — DJ console logic (uses protocol generics)
|
- =dj-session.lisp= — DJ console logic (uses protocol)
|
||||||
- All ParenScript/frontend code (channel selector, now-playing)
|
- All ParenScript/frontend code
|
||||||
- Docker path conversion, playback state persistence
|
- Docker path conversion, playback state persistence
|
||||||
|
|
||||||
** 4.2 What Lives in cl-streamer (standalone repo)
|
** 4.2 What Goes with cl-streamer
|
||||||
|
|
||||||
- =cl-streamer/= directory (git submodule)
|
- =cl-streamer/= directory (all files)
|
||||||
- =libfdkaac-shim.so= and =fdkaac-shim.c=
|
- =libfdkaac-shim.so= and =fdkaac-shim.c=
|
||||||
- Protocol definitions (=protocol.lisp=)
|
- Protocol definitions
|
||||||
- Encoder implementations (LAME MP3, FDK-AAC)
|
- Encoder implementations (LAME, FDK-AAC)
|
||||||
- Harmony backend (=harmony-backend.lisp=)
|
- Harmony backend
|
||||||
- Stream server (=stream-server.lisp=, iolib)
|
- Stream server
|
||||||
- Broadcast buffer
|
- Broadcast buffer
|
||||||
- ICY protocol
|
- ICY protocol
|
||||||
|
|
||||||
** 4.3 Future: Quicklisp/Ultralisp
|
|
||||||
|
|
||||||
Optional — register on Ultralisp once the API stabilizes. Currently loaded via
|
|
||||||
git submodule which is sufficient for Asteroid's needs.
|
|
||||||
|
|
||||||
* Step 1: Eliminate *server* Global — DONE ✓
|
|
||||||
|
|
||||||
cl-streamer c0f9c0e, asteroid 3e6b496.
|
|
||||||
|
|
||||||
- Removed =*server*= global and =ensure-server= / =start= / =stop= convenience functions
|
|
||||||
- =write-audio-data=, =set-now-playing=, =get-listener-count= now take server arg
|
|
||||||
- Added =server= slot to =streaming-drain= (mix method no longer needs global)
|
|
||||||
- =make-pipeline= no longer sets global =*server*=
|
|
||||||
- =pipeline-stop= respects =pipeline-owns-server-p= to avoid stopping shared servers
|
|
||||||
- New DJ protocol generics: =pipeline-play-voice=, =pipeline-stop-voice=,
|
|
||||||
=pipeline-stop-all-voices=, =pipeline-volume-ramp=, =pipeline-read-metadata=,
|
|
||||||
=pipeline-format-title=
|
|
||||||
- =dj-session.lisp= updated to use protocol generics (no more =harmony:*server*=
|
|
||||||
binding or =::= internal access)
|
|
||||||
- =stream-harmony.lisp= + =listener-stats.lisp= use =pipeline-listener-count=
|
|
||||||
|
|
||||||
* Step 2: Shuffle Stream as Second Pipeline — DONE ✓
|
|
||||||
|
|
||||||
asteroid 5efa493.
|
|
||||||
|
|
||||||
- Shared HTTP server (port 8000), separate mounts (=/shuffle.mp3=, =/shuffle.aac=)
|
|
||||||
- Separate Harmony instance with own MP3 + AAC encoders
|
|
||||||
- Random track selection from music library directory (2797 tracks, 1hr cache TTL)
|
|
||||||
- Auto-refill queue via =:track-change= hook (batches of 20)
|
|
||||||
- Frontend channel selector routes to correct mount URLs
|
|
||||||
- =listener-stats.lisp= polls both curated and shuffle mounts
|
|
||||||
- Fixed stale =cl-streamer:get-listener-count= calls
|
|
||||||
- Normalized all now-playing polling to 15s
|
|
||||||
|
|
||||||
* Task Checklist
|
* Task Checklist
|
||||||
|
|
||||||
** Phase 1: Protocol Definition — DONE ✓ (c79cebc)
|
** Phase 1: Protocol Definition
|
||||||
- [X] Create =cl-streamer/protocol.lisp= with protocol classes and generic functions
|
- [ ] Create =cl-streamer/protocol.lisp= with protocol classes and generic functions
|
||||||
- [X] Update =cl-streamer/package.lisp= to export protocol symbols
|
- [ ] Update =cl-streamer/package.lisp= to export protocol symbols
|
||||||
- [X] Implement protocol in =harmony-backend.lisp= (methods on existing classes)
|
- [ ] Implement protocol in =harmony-backend.lisp= (methods on existing classes)
|
||||||
- [X] Update =stream-harmony.lisp= to use protocol generics instead of direct calls
|
- [ ] Update =stream-harmony.lisp= to use protocol generics instead of direct calls
|
||||||
- [X] Verify: =make 2>&1= compiles, audio plays, metadata updates
|
- [ ] Verify: =make 2>&1= compiles, audio plays, metadata updates
|
||||||
|
|
||||||
** Phase 2: Clean Boundaries — DONE ✓ (b7266e3)
|
** Phase 2: Clean Boundaries
|
||||||
- [X] Implement =make-pipeline= DSL for declarative pipeline creation
|
- [ ] Implement =make-pipeline= DSL for declarative pipeline creation
|
||||||
- [X] Move encoder lifecycle into pipeline (pipeline owns encoders)
|
- [ ] Move encoder lifecycle into pipeline (pipeline owns encoders)
|
||||||
- [X] Move mount creation into pipeline configuration
|
- [ ] Move mount creation into pipeline configuration
|
||||||
- [X] Remove global =*server*= — pipeline-scoped server
|
- [ ] Remove global =*server*= — pipeline-scoped server
|
||||||
- [X] Add hook system for callbacks (=pipeline-add-hook= / =pipeline-remove-hook=)
|
- [ ] Add hook system for callbacks (=pipeline-add-hook= / =pipeline-remove-hook=)
|
||||||
- [X] Update =dj-session.lisp= to use protocol generics
|
- [ ] Update =dj-session.lisp= to use protocol generics
|
||||||
- [X] Verify: full integration test (auto-playlist + DJ console + skip + metadata)
|
- [ ] Verify: full integration test (auto-playlist + DJ console + skip + metadata)
|
||||||
|
|
||||||
** Phase 3: iolib Refactor — DONE ✓ (2e86dc4)
|
** Phase 3: iolib Refactor
|
||||||
- [X] Install iolib and libfixposix on dev machine
|
- [ ] Install iolib and libfixposix on dev machine
|
||||||
- [X] Rewrite =stream-server.lisp= socket layer to use iolib
|
- [ ] Rewrite =stream-server.lisp= socket layer to use iolib
|
||||||
- [X] Add SO_KEEPALIVE and TCP_NODELAY to client sockets
|
- [ ] Add SO_KEEPALIVE and TCP_NODELAY to client sockets
|
||||||
- [X] Add write timeouts for stale client detection
|
- [ ] Add write timeouts for stale client detection
|
||||||
- [X] Update =cl-streamer.asd= dependencies
|
- [ ] Update =cl-streamer.asd= dependencies
|
||||||
- [X] Stress test: multiple concurrent listeners, client disconnect scenarios
|
- [ ] Stress test: multiple concurrent listeners, client disconnect scenarios
|
||||||
- [ ] Test on VPS (ensure libfixposix available)
|
- [ ] Test on VPS (ensure libfixposix available)
|
||||||
|
|
||||||
** Phase 4: Extraction — DONE ✓ (8d9d2b3)
|
** Phase 4: Extraction (Future / Optional)
|
||||||
- [X] Create separate git repository for cl-streamer
|
- [ ] Create separate git repository for cl-streamer
|
||||||
- [X] Write proper README with usage examples
|
- [ ] Write proper README with usage examples
|
||||||
- [ ] Register on Ultralisp for Quicklisp distribution (optional, deferred)
|
- [ ] Register on Ultralisp for Quicklisp distribution
|
||||||
- [X] Update Asteroid to load cl-streamer as external dependency (git submodule)
|
- [ ] Update Asteroid to load cl-streamer as external dependency
|
||||||
|
|
||||||
** Step 1: Eliminate *server* Global — DONE ✓ (c0f9c0e / 3e6b496)
|
|
||||||
- [X] Remove =*server*= global from =cl-streamer.lisp=
|
|
||||||
- [X] Make =write-audio-data=, =set-now-playing=, =get-listener-count= take server arg
|
|
||||||
- [X] Add DJ protocol generics
|
|
||||||
- [X] Update =dj-session.lisp= to use protocol generics
|
|
||||||
- [X] Update =stream-harmony.lisp= + =listener-stats.lisp= to use =pipeline-listener-count=
|
|
||||||
- [X] Build verified, runtime tested
|
|
||||||
|
|
||||||
** Step 2: Shuffle Stream — DONE ✓ (5efa493)
|
|
||||||
- [X] Implement shuffle pipeline in =stream-harmony.lisp=
|
|
||||||
- [X] Wire shuffle start/stop into =asteroid.lisp= startup/shutdown/restart
|
|
||||||
- [X] Add shuffle mounts to listener stats polling
|
|
||||||
- [X] Frontend channel selector routes to shuffle mounts
|
|
||||||
- [X] Build verified, runtime tested (both streams play simultaneously)
|
|
||||||
|
|
||||||
** Remaining Work
|
|
||||||
- [ ] Step 3: Full integration test (auto-playlist + DJ console + shuffle + skip + metadata)
|
|
||||||
- [ ] Step 4: External encoder source protocol (Icecast source compatibility)
|
|
||||||
- [ ] VPS deployment (libfixposix needed for iolib)
|
|
||||||
|
|
||||||
* Notes
|
* Notes
|
||||||
|
|
||||||
- Asteroid branch: =glenneth/cl-streamer-standalone=
|
- Branch: =glenneth/cl-streamer-standalone= (off =experimental/cl-streaming=)
|
||||||
- cl-streamer repo: https://github.com/glenneth1/cl-streamer (default branch: =master=)
|
- The current =experimental/cl-streaming= branch remains the stable working version
|
||||||
- Each phase ended with a working, tested system
|
- Each phase should end with a working, tested system
|
||||||
|
- Phase 1 is the highest priority — it establishes the protocol without breaking anything
|
||||||
|
- Phase 3 (iolib) can be done independently of Phase 2
|
||||||
- The C shim for FDK-AAC (=libfdkaac-shim.so=) travels with cl-streamer — it's
|
- The C shim for FDK-AAC (=libfdkaac-shim.so=) travels with cl-streamer — it's
|
||||||
needed to avoid SBCL signal handler conflicts with FDK-AAC internals
|
needed to avoid SBCL signal handler conflicts with FDK-AAC internals
|
||||||
- TLS is handled externally (HAProxy or similar), not in cl-streamer
|
|
||||||
- Relay clustering deemed unnecessary for current scale
|
|
||||||
|
|
|
||||||
|
|
@ -55,18 +55,14 @@
|
||||||
"DJ Live"))
|
"DJ Live"))
|
||||||
(owner (or (cdr (assoc "owner" status :test #'string=)) "DJ"))
|
(owner (or (cdr (assoc "owner" status :test #'string=)) "DJ"))
|
||||||
(title (format nil "~A [DJ: ~A]" display-title owner))
|
(title (format nil "~A [DJ: ~A]" display-title owner))
|
||||||
(listeners (if *harmony-pipeline*
|
(listeners (or (cl-streamer:get-listener-count) 0)))
|
||||||
(or (cl-streamer:pipeline-listener-count *harmony-pipeline*) 0)
|
|
||||||
0)))
|
|
||||||
`((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount))
|
`((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount))
|
||||||
(:title . ,title)
|
(:title . ,title)
|
||||||
(:listeners . ,listeners)
|
(:listeners . ,listeners)
|
||||||
(:track-id . nil)
|
(:track-id . nil)
|
||||||
(:favorite-count . 0)))
|
(:favorite-count . 0)))
|
||||||
;; Normal mode — route to curated or shuffle based on mount name
|
;; Normal auto-playlist mode
|
||||||
(if (search "shuffle" mount :test #'char-equal)
|
(harmony-now-playing mount)))
|
||||||
(shuffle-now-playing mount)
|
|
||||||
(harmony-now-playing mount))))
|
|
||||||
|
|
||||||
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 30 :timeout 60)
|
(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 30 :timeout 60)
|
||||||
"Get Partial HTML with live now-playing status.
|
"Get Partial HTML with live now-playing status.
|
||||||
|
|
|
||||||
|
|
@ -384,20 +384,12 @@
|
||||||
|
|
||||||
(defun poll-and-store-stats ()
|
(defun poll-and-store-stats ()
|
||||||
"Single poll iteration: fetch listener counts from cl-streamer and store."
|
"Single poll iteration: fetch listener counts from cl-streamer and store."
|
||||||
;; Curated stream mounts
|
|
||||||
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
(dolist (mount '("/asteroid.mp3" "/asteroid.aac"))
|
||||||
(let ((listeners (when *harmony-pipeline*
|
(let ((listeners (when *harmony-pipeline*
|
||||||
(cl-streamer:pipeline-listener-count *harmony-pipeline* mount))))
|
(cl-streamer:pipeline-listener-count *harmony-pipeline* 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))))
|
||||||
;; Shuffle stream mounts
|
|
||||||
(dolist (mount '("/shuffle.mp3" "/shuffle.aac"))
|
|
||||||
(let ((listeners (when *shuffle-pipeline*
|
|
||||||
(cl-streamer:pipeline-listener-count *shuffle-pipeline* mount))))
|
|
||||||
(when (and listeners (> listeners 0))
|
|
||||||
(store-listener-snapshot mount listeners)
|
|
||||||
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))
|
|
||||||
;; Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)
|
;; Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)
|
||||||
(collect-geo-stats-from-web-listeners))
|
(collect-geo-stats-from-web-listeners))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -746,8 +746,8 @@
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
;; Update now playing every 15 seconds
|
;; Update now playing every 5 seconds
|
||||||
(set-interval update-now-playing 15000)
|
(set-interval update-now-playing 5000)
|
||||||
|
|
||||||
;; Poll server for channel name changes (works across all listeners)
|
;; Poll server for channel name changes (works across all listeners)
|
||||||
(let ((last-channel-name nil))
|
(let ((last-channel-name nil))
|
||||||
|
|
|
||||||
|
|
@ -810,8 +810,8 @@
|
||||||
|
|
||||||
;; Initial update after 1 second
|
;; Initial update after 1 second
|
||||||
(set-timeout update-now-playing 1000)
|
(set-timeout update-now-playing 1000)
|
||||||
;; Update live stream info every 15 seconds
|
;; Update live stream info every 10 seconds
|
||||||
(set-interval update-now-playing 15000)
|
(set-interval update-now-playing 10000)
|
||||||
|
|
||||||
;; Make functions globally accessible for onclick handlers
|
;; Make functions globally accessible for onclick handlers
|
||||||
(defvar window (ps:@ window))
|
(defvar window (ps:@ window))
|
||||||
|
|
|
||||||
|
|
@ -149,23 +149,23 @@
|
||||||
;; ========================================
|
;; ========================================
|
||||||
|
|
||||||
;; Get stream configuration for a given channel and quality
|
;; Get stream configuration for a given channel and quality
|
||||||
;; Curated channel uses /asteroid.* mounts, shuffle uses /shuffle.* mounts
|
;; With cl-streamer, both channels use the same stream mounts -
|
||||||
|
;; channel switching loads a different playlist server-side
|
||||||
(defun get-stream-config (stream-base-url channel quality)
|
(defun get-stream-config (stream-base-url channel quality)
|
||||||
(let ((prefix (if (= channel "shuffle") "/shuffle" "/asteroid")))
|
(let ((config (ps:create
|
||||||
(let ((config (ps:create
|
:aac (ps:create :url (+ stream-base-url "/asteroid.aac")
|
||||||
:aac (ps:create :url (+ stream-base-url prefix ".aac")
|
:type "audio/aac"
|
||||||
:type "audio/aac"
|
:format "AAC 96kbps Stereo"
|
||||||
:format "AAC 96kbps Stereo"
|
:mount "asteroid.aac")
|
||||||
:mount (+ (ps:chain prefix (substring 1)) ".aac"))
|
:mp3 (ps:create :url (+ stream-base-url "/asteroid.mp3")
|
||||||
:mp3 (ps:create :url (+ stream-base-url prefix ".mp3")
|
:type "audio/mpeg"
|
||||||
:type "audio/mpeg"
|
:format "MP3 128kbps Stereo"
|
||||||
:format "MP3 128kbps Stereo"
|
:mount "asteroid.mp3")
|
||||||
:mount (+ (ps:chain prefix (substring 1)) ".mp3"))
|
:low (ps:create :url (+ stream-base-url "/asteroid.mp3")
|
||||||
:low (ps:create :url (+ stream-base-url prefix ".mp3")
|
:type "audio/mpeg"
|
||||||
:type "audio/mpeg"
|
:format "MP3 128kbps Stereo"
|
||||||
:format "MP3 128kbps Stereo"
|
:mount "asteroid.mp3"))))
|
||||||
:mount (+ (ps:chain prefix (substring 1)) ".mp3")))))
|
(ps:getprop config quality)))
|
||||||
(ps:getprop config quality))))
|
|
||||||
|
|
||||||
;; Get current channel from selector or localStorage
|
;; Get current channel from selector or localStorage
|
||||||
(defun get-current-channel ()
|
(defun get-current-channel ()
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@
|
||||||
(defvar *harmony-stream-port* 8000
|
(defvar *harmony-stream-port* 8000
|
||||||
"Port for the cl-streamer HTTP stream server.")
|
"Port for the cl-streamer HTTP stream server.")
|
||||||
|
|
||||||
(defvar *shuffle-pipeline* nil
|
|
||||||
"The shuffle stream pipeline — plays random tracks from the music library.")
|
|
||||||
|
|
||||||
;; Encoder instances are now owned by the pipeline (Phase 2).
|
;; Encoder instances are now owned by the pipeline (Phase 2).
|
||||||
;; Kept as aliases for backward compatibility with any external references.
|
;; Kept as aliases for backward compatibility with any external references.
|
||||||
(defun harmony-mp3-encoder ()
|
(defun harmony-mp3-encoder ()
|
||||||
|
|
@ -297,155 +294,3 @@
|
||||||
:queue-length (length (cl-streamer/harmony:pipeline-get-queue
|
:queue-length (length (cl-streamer/harmony:pipeline-get-queue
|
||||||
*harmony-pipeline*))))
|
*harmony-pipeline*))))
|
||||||
(list :running nil)))
|
(list :running nil)))
|
||||||
|
|
||||||
;;; ============================================================
|
|
||||||
;;; Shuffle Stream — random tracks from the music library
|
|
||||||
;;; ============================================================
|
|
||||||
|
|
||||||
(defvar *shuffle-batch-size* 20
|
|
||||||
"Number of tracks to queue at a time on the shuffle pipeline.")
|
|
||||||
|
|
||||||
(defun scan-music-library-files (&optional (directory *music-library-path*))
|
|
||||||
"Recursively scan DIRECTORY for supported audio files.
|
|
||||||
Returns a list of namestrings."
|
|
||||||
(let ((files nil)
|
|
||||||
(extensions *supported-formats*))
|
|
||||||
(labels ((scan (dir)
|
|
||||||
(handler-case
|
|
||||||
(dolist (entry (uiop:directory-files dir))
|
|
||||||
(let ((ext (pathname-type entry)))
|
|
||||||
(when (and ext (member ext extensions :test #'string-equal))
|
|
||||||
(push (namestring entry) files))))
|
|
||||||
(error (e)
|
|
||||||
(log:debug "Error scanning ~A: ~A" dir e)))
|
|
||||||
(handler-case
|
|
||||||
(dolist (sub (uiop:subdirectories dir))
|
|
||||||
(scan sub))
|
|
||||||
(error (e)
|
|
||||||
(log:debug "Error listing subdirs of ~A: ~A" dir e)))))
|
|
||||||
(scan (pathname directory)))
|
|
||||||
(nreverse files)))
|
|
||||||
|
|
||||||
(defvar *shuffle-library-cache* nil
|
|
||||||
"Cached list of audio files from the music library for shuffle.")
|
|
||||||
|
|
||||||
(defvar *shuffle-library-cache-time* 0
|
|
||||||
"Universal time when *shuffle-library-cache* was last refreshed.")
|
|
||||||
|
|
||||||
(defvar *shuffle-cache-ttl* 3600
|
|
||||||
"Seconds before the shuffle library cache expires (default 1 hour).")
|
|
||||||
|
|
||||||
(defun get-shuffle-library ()
|
|
||||||
"Return the cached list of music library files, refreshing if stale."
|
|
||||||
(when (or (null *shuffle-library-cache*)
|
|
||||||
(> (- (get-universal-time) *shuffle-library-cache-time*)
|
|
||||||
*shuffle-cache-ttl*))
|
|
||||||
(log:info "Scanning music library for shuffle pool...")
|
|
||||||
(let ((files (scan-music-library-files)))
|
|
||||||
(setf *shuffle-library-cache* files
|
|
||||||
*shuffle-library-cache-time* (get-universal-time))
|
|
||||||
(log:info "Shuffle pool: ~A tracks" (length files))))
|
|
||||||
*shuffle-library-cache*)
|
|
||||||
|
|
||||||
(defun shuffle-random-batch (&optional (n *shuffle-batch-size*))
|
|
||||||
"Pick N random tracks from the music library (with replacement for small libs)."
|
|
||||||
(let ((library (get-shuffle-library)))
|
|
||||||
(when library
|
|
||||||
(let ((len (length library)))
|
|
||||||
(loop repeat (min n len)
|
|
||||||
collect (list :file (nth (random len) library)))))))
|
|
||||||
|
|
||||||
(defun refill-shuffle-queue ()
|
|
||||||
"Queue another batch of random tracks on the shuffle pipeline.
|
|
||||||
Called by the track-change hook when the queue is running low."
|
|
||||||
(when *shuffle-pipeline*
|
|
||||||
(let ((queue-len (length (cl-streamer/harmony:pipeline-get-queue *shuffle-pipeline*))))
|
|
||||||
(when (< queue-len (floor *shuffle-batch-size* 2))
|
|
||||||
(let ((batch (shuffle-random-batch)))
|
|
||||||
(when batch
|
|
||||||
(cl-streamer/harmony:pipeline-queue-files *shuffle-pipeline* batch)
|
|
||||||
(log:debug "Shuffle: queued ~A tracks (~A in queue)"
|
|
||||||
(length batch) (+ queue-len (length batch)))))))))
|
|
||||||
|
|
||||||
(defun on-shuffle-track-change (pipeline track-info)
|
|
||||||
"Called by cl-streamer when the shuffle stream changes tracks.
|
|
||||||
Updates the shuffle recently-played list and refills the queue."
|
|
||||||
(declare (ignore pipeline))
|
|
||||||
(let* ((display-title (getf track-info :display-title))
|
|
||||||
(artist (getf track-info :artist))
|
|
||||||
(title (getf track-info :title))
|
|
||||||
(file-path (getf track-info :file))
|
|
||||||
(track-id (or (find-track-by-title display-title)
|
|
||||||
(find-track-by-file-path file-path))))
|
|
||||||
(when (and display-title (not (string= display-title "Unknown")))
|
|
||||||
(add-recently-played (list :title display-title
|
|
||||||
:artist artist
|
|
||||||
:song title
|
|
||||||
:timestamp (get-universal-time)
|
|
||||||
:track-id track-id)
|
|
||||||
:shuffle)
|
|
||||||
(setf *last-known-track-shuffle* display-title))
|
|
||||||
(log:info "Shuffle track change: ~A" display-title))
|
|
||||||
(refill-shuffle-queue))
|
|
||||||
|
|
||||||
(defun shuffle-now-playing (&optional (mount "shuffle.mp3"))
|
|
||||||
"Get now-playing information from the shuffle pipeline."
|
|
||||||
(when (and *shuffle-pipeline*
|
|
||||||
(cl-streamer/harmony:pipeline-current-track *shuffle-pipeline*))
|
|
||||||
(let* ((track-info (cl-streamer/harmony:pipeline-current-track *shuffle-pipeline*))
|
|
||||||
(display-title (or (getf track-info :display-title) "Unknown"))
|
|
||||||
(listeners (cl-streamer:pipeline-listener-count *shuffle-pipeline*)))
|
|
||||||
`((:listenurl . ,(format nil "~A/~A" *stream-base-url* mount))
|
|
||||||
(:title . ,display-title)
|
|
||||||
(:listeners . ,(or listeners 0))
|
|
||||||
(:track-id . nil)
|
|
||||||
(:favorite-count . 0)))))
|
|
||||||
|
|
||||||
;;; ---- Shuffle Pipeline Lifecycle ----
|
|
||||||
|
|
||||||
(defun start-shuffle-streaming (&key (mp3-bitrate 128) (aac-bitrate 128))
|
|
||||||
"Start the shuffle pipeline, sharing the curated pipeline's stream server.
|
|
||||||
Must be called after start-harmony-streaming."
|
|
||||||
(when *shuffle-pipeline*
|
|
||||||
(log:warn "Shuffle streaming already running")
|
|
||||||
(return-from start-shuffle-streaming *shuffle-pipeline*))
|
|
||||||
(unless *harmony-pipeline*
|
|
||||||
(error "Cannot start shuffle pipeline: curated pipeline not running"))
|
|
||||||
(let ((shared-server (cl-streamer/harmony:pipeline-server *harmony-pipeline*)))
|
|
||||||
(setf *shuffle-pipeline*
|
|
||||||
(cl-streamer/harmony:make-pipeline
|
|
||||||
:server shared-server
|
|
||||||
:outputs (list (list :format :mp3
|
|
||||||
:mount "/shuffle.mp3"
|
|
||||||
:bitrate mp3-bitrate
|
|
||||||
:name "Asteroid Radio Shuffle MP3")
|
|
||||||
(list :format :aac
|
|
||||||
:mount "/shuffle.aac"
|
|
||||||
:bitrate aac-bitrate
|
|
||||||
:name "Asteroid Radio Shuffle AAC"))))
|
|
||||||
;; Register hooks
|
|
||||||
(cl-streamer/harmony:pipeline-add-hook *shuffle-pipeline*
|
|
||||||
:track-change #'on-shuffle-track-change)
|
|
||||||
;; Seed the queue before starting
|
|
||||||
(let ((batch (shuffle-random-batch)))
|
|
||||||
(when batch
|
|
||||||
(cl-streamer/harmony:pipeline-queue-files *shuffle-pipeline* batch)))
|
|
||||||
;; Start the pipeline and begin playback
|
|
||||||
(cl-streamer/harmony:pipeline-start *shuffle-pipeline*)
|
|
||||||
;; Start the play-list loop (plays queued tracks, refill hook keeps it going)
|
|
||||||
(let ((initial-files (mapcar (lambda (entry) (getf entry :file))
|
|
||||||
(cl-streamer/harmony:pipeline-get-queue *shuffle-pipeline*))))
|
|
||||||
(when initial-files
|
|
||||||
(cl-streamer/harmony:play-list *shuffle-pipeline* initial-files
|
|
||||||
:crossfade-duration 3.0
|
|
||||||
:loop-queue t)))
|
|
||||||
(log:info "Shuffle streaming started (MP3 + AAC, ~A tracks in pool)"
|
|
||||||
(length (get-shuffle-library)))
|
|
||||||
*shuffle-pipeline*))
|
|
||||||
|
|
||||||
(defun stop-shuffle-streaming ()
|
|
||||||
"Stop the shuffle pipeline. Does not stop the shared server."
|
|
||||||
(when *shuffle-pipeline*
|
|
||||||
(cl-streamer/harmony:pipeline-stop *shuffle-pipeline*)
|
|
||||||
(setf *shuffle-pipeline* nil))
|
|
||||||
(log:info "Shuffle streaming stopped"))
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue