@@ -145,3 +145,194 @@ pub async fn play_track(
145145 }
146146 }
147147}
148+
149+ // LMS (Lyrion Music Server) Spotify Connect integration
150+
151+ use librespot:: playback:: audio_backend:: { Sink , SinkResult } ;
152+ use librespot:: playback:: convert:: Converter ;
153+ use librespot:: playback:: decoder:: AudioPacket ;
154+ use librespot:: playback:: player:: PlayerEvent ;
155+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
156+ use std:: sync:: Arc ;
157+ use std:: time:: Instant ;
158+ use tokio:: io:: AsyncWriteExt ;
159+ use tokio:: net:: TcpStream ;
160+
161+ /// Rate-limited null sink for Spotify Connect daemon mode.
162+ ///
163+ /// Discards decoded audio while sleeping between writes to maintain accurate
164+ /// real-time playback position (needed so Spirc reports correct state to Spotify).
165+ /// Unlike the pipe/StdoutSink, this sink does NOT call exit() on stop(), allowing
166+ /// the Connect daemon to handle track transitions and pause/resume cleanly.
167+ pub struct ConnectNullSink {
168+ start : Instant ,
169+ frames : u64 ,
170+ }
171+
172+ impl ConnectNullSink {
173+ pub fn open ( _device : Option < String > , _format : AudioFormat ) -> Box < dyn Sink > {
174+ Box :: new ( Self {
175+ start : Instant :: now ( ) ,
176+ frames : 0 ,
177+ } )
178+ }
179+ }
180+
181+ impl Sink for ConnectNullSink {
182+ fn start ( & mut self ) -> SinkResult < ( ) > {
183+ self . start = Instant :: now ( ) ;
184+ self . frames = 0 ;
185+ Ok ( ( ) )
186+ }
187+
188+ fn write ( & mut self , packet : AudioPacket , _: & mut Converter ) -> SinkResult < ( ) > {
189+ if let AudioPacket :: Samples ( samples) = packet {
190+ // samples is stereo-interleaved f64; each pair is one frame
191+ self . frames += ( samples. len ( ) / librespot:: playback:: NUM_CHANNELS as usize ) as u64 ;
192+ let expected_ns = self . frames * 1_000_000_000 / librespot:: playback:: SAMPLE_RATE as u64 ;
193+ let elapsed_ns = self . start . elapsed ( ) . as_nanos ( ) as u64 ;
194+ if expected_ns > elapsed_ns {
195+ std:: thread:: sleep ( std:: time:: Duration :: from_nanos ( expected_ns - elapsed_ns) ) ;
196+ }
197+ }
198+ Ok ( ( ) )
199+ }
200+ }
201+
202+ #[ derive( Clone ) ]
203+ pub struct LMS {
204+ host_port : Option < String > ,
205+ player_mac : Option < String > ,
206+ auth : Option < String > ,
207+ /// Set to true when Spirc activates the session; the very next VolumeChanged
208+ /// event is Spotify's stored device volume being pushed back to us, not a
209+ /// user-driven change. We suppress it to avoid clobbering the LMS player's
210+ /// current volume.
211+ suppress_next_volume : Arc < AtomicBool > ,
212+ }
213+
214+ impl LMS {
215+ pub fn new (
216+ host_port : Option < String > ,
217+ player_mac : Option < String > ,
218+ auth : Option < String > ,
219+ ) -> Self {
220+ Self {
221+ host_port,
222+ player_mac,
223+ auth : auth. map ( |a| a. trim ( ) . to_string ( ) ) ,
224+ suppress_next_volume : Arc :: new ( AtomicBool :: new ( false ) ) ,
225+ }
226+ }
227+
228+ pub fn is_configured ( & self ) -> bool {
229+ self . host_port . is_some ( ) && self . player_mac . is_some ( )
230+ }
231+
232+ async fn notify ( & self , cmd : & str , param1 : & str , param2 : & str ) {
233+ let ( host_port, player_mac) = match ( & self . host_port , & self . player_mac ) {
234+ ( Some ( h) , Some ( m) ) => ( h. as_str ( ) , m. as_str ( ) ) ,
235+ _ => return ,
236+ } ;
237+
238+ let mut cmd_array: Vec < serde_json:: Value > =
239+ vec ! [ serde_json:: json!( "spottyconnect" ) , serde_json:: json!( cmd) ] ;
240+ if !param1. is_empty ( ) {
241+ cmd_array. push ( serde_json:: json!( param1) ) ;
242+ }
243+ if !param2. is_empty ( ) {
244+ cmd_array. push ( serde_json:: json!( param2) ) ;
245+ }
246+
247+ let body = serde_json:: json!( {
248+ "id" : 1 ,
249+ "method" : "slim.request" ,
250+ "params" : [ player_mac, cmd_array] ,
251+ } )
252+ . to_string ( ) ;
253+
254+ let auth_line = self
255+ . auth
256+ . as_ref ( )
257+ . map ( |a| format ! ( "Authorization: Basic {a}\r \n " ) )
258+ . unwrap_or_default ( ) ;
259+
260+ let request = format ! (
261+ "POST /jsonrpc.js HTTP/1.0\r \n Host: {host_port}\r \n Content-Type: application/json\r \n Content-Length: {len}\r \n {auth_line}\r \n {body}" ,
262+ len = body. len( )
263+ ) ;
264+
265+ match TcpStream :: connect ( host_port) . await {
266+ Ok ( mut stream) => {
267+ if let Err ( e) = stream. write_all ( request. as_bytes ( ) ) . await {
268+ warn ! ( "LMS notification write failed: {e}" ) ;
269+ }
270+ }
271+ Err ( e) => {
272+ warn ! ( "Failed to connect to LMS at {host_port}: {e}" ) ;
273+ }
274+ }
275+ }
276+
277+ pub async fn handle_player_event (
278+ & self ,
279+ event : & PlayerEvent ,
280+ current_track : & mut Option < String > ,
281+ ) {
282+ match event {
283+ PlayerEvent :: Playing { track_id, .. } => {
284+ let id = match track_id. to_id ( ) {
285+ Ok ( id) => id,
286+ Err ( e) => {
287+ warn ! ( "LMS: failed to get track id: {e}" ) ;
288+ return ;
289+ }
290+ } ;
291+ if current_track. as_deref ( ) == Some ( id. as_str ( ) ) {
292+ // Same track (e.g. seek or buffer-underrun re-emit), no action needed
293+ return ;
294+ }
295+ let old = current_track. replace ( id. clone ( ) ) ;
296+ if let Some ( old_id) = old {
297+ self . notify ( "change" , & id, & old_id) . await ;
298+ } else {
299+ self . notify ( "start" , & id, "" ) . await ;
300+ }
301+ }
302+ PlayerEvent :: Stopped { .. } | PlayerEvent :: Paused { .. } => {
303+ if current_track. take ( ) . is_some ( ) {
304+ self . notify ( "stop" , "" , "" ) . await ;
305+ }
306+ }
307+ PlayerEvent :: VolumeChanged { volume } => {
308+ // Suppress the activation-time volume push from Spotify. When
309+ // Spirc connects to Spotify it immediately emits the device's
310+ // last-remembered volume (SessionConnected fires first, setting
311+ // this flag). That value comes from Spotify's state, not the
312+ // user, and would overwrite whatever LMS had set.
313+ if self . suppress_next_volume . swap ( false , Ordering :: Relaxed ) {
314+ info ! ( "LMS: suppressing activation-time volume reset from Spotify ({} -> {}%)" ,
315+ volume, * volume as u64 * 100 / 65535 ) ;
316+ return ;
317+ }
318+ let pct = ( * volume as u64 * 100 / 65535 ) . to_string ( ) ;
319+ self . notify ( "volume" , & pct, "" ) . await ;
320+ }
321+ PlayerEvent :: Seeked { position_ms, .. } => {
322+ // Send the exact position directly so the Perl handler can
323+ // seek LMS immediately without querying the REST API (which
324+ // frequently lags behind Spirc's WebSocket state by 500ms+).
325+ if current_track. is_some ( ) {
326+ let pos_secs = ( * position_ms as f64 / 1000.0 ) . to_string ( ) ;
327+ self . notify ( "seek" , & pos_secs, "" ) . await ;
328+ }
329+ }
330+ PlayerEvent :: SessionConnected { .. } => {
331+ // The next VolumeChanged will be Spirc pushing Spotify's stored
332+ // device volume; flag it for suppression.
333+ self . suppress_next_volume . store ( true , Ordering :: Relaxed ) ;
334+ }
335+ _ => { }
336+ }
337+ }
338+ }
0 commit comments