@lox-audioserver/node-librespot 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,2305 @@
1
+ use std::fs::{self, File};
2
+ use std::io::Read;
3
+ use std::path::Path;
4
+ use std::sync::{
5
+ atomic::{AtomicBool, AtomicU64, Ordering},
6
+ Arc, Mutex, OnceLock,
7
+ };
8
+ use std::time::Duration;
9
+ use std::time::{SystemTime, UNIX_EPOCH};
10
+
11
+ use bytes::Bytes;
12
+ use librespot_audio::{AudioDecrypt, AudioFile};
13
+ use librespot_connect::{ConnectConfig, Spirc};
14
+ use librespot_core::{
15
+ authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, SpotifyId,
16
+ SpotifyUri, spotify_id::FileId,
17
+ };
18
+ use librespot_discovery::{DeviceType, Discovery};
19
+ use librespot_metadata::audio::{AudioFileFormat, AudioFiles, AudioItem};
20
+ use librespot_metadata::{Album, Artist, Metadata, Track};
21
+ use librespot_playback::{
22
+ audio_backend::{Sink, SinkResult},
23
+ config::{AudioFormat, Bitrate, PlayerConfig},
24
+ convert::Converter,
25
+ decoder::AudioPacket,
26
+ mixer::{softmixer::SoftMixer, Mixer, MixerConfig, NoOpVolume, VolumeGetter},
27
+ player::{Player, PlayerEvent},
28
+ };
29
+ use log::{LevelFilter, Log, Metadata as LogMetadata, Record};
30
+ use napi::bindgen_prelude::{Error, Result};
31
+ use napi::threadsafe_function::{
32
+ ErrorStrategy, ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode,
33
+ };
34
+ use napi::JsFunction;
35
+ use napi_derive::napi;
36
+ use serde_json;
37
+ use std::thread::sleep;
38
+ use std::time::Instant;
39
+ use tokio::sync::mpsc;
40
+ use tokio::time::timeout;
41
+ use tokio_stream::StreamExt;
42
+
43
+ static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
44
+ static LOGGER_INIT: OnceLock<()> = OnceLock::new();
45
+ static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
46
+
47
+ fn next_session_id(prefix: &str) -> String {
48
+ let next = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
49
+ format!("{prefix}-{next}")
50
+ }
51
+
52
+ fn runtime() -> &'static tokio::runtime::Runtime {
53
+ RUNTIME.get_or_init(|| {
54
+ tokio::runtime::Runtime::new().expect("failed to create tokio runtime for librespot addon")
55
+ })
56
+ }
57
+
58
+ fn stream_data_rate(format: AudioFileFormat) -> Option<usize> {
59
+ let kbps = match format {
60
+ AudioFileFormat::OGG_VORBIS_96 => 12.,
61
+ AudioFileFormat::OGG_VORBIS_160 => 20.,
62
+ AudioFileFormat::OGG_VORBIS_320 => 40.,
63
+ AudioFileFormat::MP3_256 => 32.,
64
+ AudioFileFormat::MP3_320 => 40.,
65
+ AudioFileFormat::MP3_160 => 20.,
66
+ AudioFileFormat::MP3_96 => 12.,
67
+ AudioFileFormat::MP3_160_ENC => 20.,
68
+ AudioFileFormat::AAC_24 => 3.,
69
+ AudioFileFormat::AAC_48 => 6.,
70
+ AudioFileFormat::AAC_160 => 20.,
71
+ AudioFileFormat::AAC_320 => 40.,
72
+ AudioFileFormat::MP4_128 => 16.,
73
+ AudioFileFormat::OTHER5 => 40.,
74
+ AudioFileFormat::FLAC_FLAC => 112., // assume ~900 kbit/s
75
+ AudioFileFormat::XHE_AAC_12 => 1.5,
76
+ AudioFileFormat::XHE_AAC_16 => 2.,
77
+ AudioFileFormat::XHE_AAC_24 => 3.,
78
+ AudioFileFormat::FLAC_FLAC_24BIT => 3.,
79
+ };
80
+ let data_rate: f32 = kbps * 1024.;
81
+ Some(data_rate.ceil() as usize)
82
+ }
83
+
84
+ /// Options used to create a librespot session.
85
+ #[napi(object)]
86
+ pub struct CreateSessionOpts {
87
+ pub access_token: Option<String>,
88
+ pub client_id: Option<String>,
89
+ pub device_name: Option<String>,
90
+ }
91
+
92
+ /// Options for streaming a track.
93
+ #[napi(object)]
94
+ pub struct StreamTrackOpts {
95
+ pub uri: String,
96
+ pub start_position_ms: Option<u32>,
97
+ pub bitrate: Option<u32>,
98
+ pub output: Option<String>,
99
+ pub emit_events: Option<bool>,
100
+ }
101
+
102
+ #[napi(object)]
103
+ pub struct DownloadTrackOpts {
104
+ pub uri: String,
105
+ pub bitrate: Option<u32>,
106
+ }
107
+
108
+ /// Result of a credentials login flow.
109
+ #[napi(object)]
110
+ pub struct CredentialsResult {
111
+ pub username: String,
112
+ pub credentials_json: String,
113
+ }
114
+
115
+ /// Event payload emitted by the connect host.
116
+ #[napi(object)]
117
+ pub struct ConnectEvent {
118
+ pub r#type: String,
119
+ pub device_id: Option<String>,
120
+ pub session_id: Option<String>,
121
+ pub track_id: Option<String>,
122
+ pub uri: Option<String>,
123
+ pub title: Option<String>,
124
+ pub artist: Option<String>,
125
+ pub album: Option<String>,
126
+ pub duration_ms: Option<u32>,
127
+ pub position_ms: Option<u32>,
128
+ pub volume: Option<u16>,
129
+ pub error_code: Option<String>,
130
+ pub error_message: Option<String>,
131
+ pub metric_name: Option<String>,
132
+ pub metric_value_ms: Option<u32>,
133
+ pub metric_message: Option<String>,
134
+ }
135
+
136
+ /// Log payload emitted by the native module.
137
+ #[napi(object)]
138
+ #[derive(Clone)]
139
+ pub struct LogEvent {
140
+ pub level: String,
141
+ pub message: String,
142
+ pub scope: Option<String>,
143
+ pub device_id: Option<String>,
144
+ pub session_id: Option<String>,
145
+ }
146
+
147
+ /// Handle returned when hosting a connect device (placeholder).
148
+ #[napi]
149
+ pub struct ConnectHandle {
150
+ spirc: Spirc,
151
+ stop_tx: Option<mpsc::Sender<()>>,
152
+ #[allow(dead_code)]
153
+ tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal>,
154
+ sample_rate: u32,
155
+ channels: u16,
156
+ stop_flag: Arc<AtomicBool>,
157
+ task: tokio::task::JoinHandle<()>,
158
+ }
159
+
160
+ #[napi]
161
+ impl ConnectHandle {
162
+ #[napi]
163
+ pub fn stop(&mut self) {
164
+ self.stop_flag.store(true, Ordering::Release);
165
+ if let Some(tx) = self.stop_tx.take() {
166
+ let _ = tx.try_send(());
167
+ }
168
+ let _ = self.spirc.shutdown();
169
+ self.task.abort();
170
+ }
171
+
172
+ #[napi]
173
+ pub fn shutdown(&mut self) {
174
+ self.stop();
175
+ }
176
+
177
+ #[napi]
178
+ pub fn close(&mut self) {
179
+ self.stop();
180
+ }
181
+
182
+ #[napi]
183
+ pub fn play(&self) {
184
+ let _ = self.spirc.play();
185
+ }
186
+
187
+ #[napi]
188
+ pub fn pause(&self) {
189
+ let _ = self.spirc.pause();
190
+ }
191
+
192
+ #[napi]
193
+ pub fn next(&self) {
194
+ let _ = self.spirc.next();
195
+ }
196
+
197
+ #[napi]
198
+ pub fn prev(&self) {
199
+ let _ = self.spirc.prev();
200
+ }
201
+
202
+ #[napi(getter)]
203
+ pub fn sample_rate(&self) -> u32 {
204
+ self.sample_rate
205
+ }
206
+
207
+ #[napi(getter)]
208
+ pub fn channels(&self) -> u16 {
209
+ self.channels
210
+ }
211
+ }
212
+
213
+ /// Handle to a librespot session.
214
+ #[napi]
215
+ pub struct LibrespotSession {
216
+ session: Session,
217
+ player_config: PlayerConfig,
218
+ device_id: String,
219
+ }
220
+
221
+ #[napi]
222
+ pub struct StreamHandle {
223
+ stop_tx: Option<mpsc::Sender<()>>,
224
+ #[allow(dead_code)]
225
+ tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal>,
226
+ sample_rate: u32,
227
+ channels: u16,
228
+ #[allow(dead_code)]
229
+ event_tsfn: Option<ThreadsafeFunction<ConnectEvent, ErrorStrategy::Fatal>>,
230
+ }
231
+
232
+ #[napi]
233
+ pub struct DownloadHandle {
234
+ stop_flag: Arc<AtomicBool>,
235
+ #[allow(dead_code)]
236
+ task: tokio::task::JoinHandle<()>,
237
+ #[allow(dead_code)]
238
+ tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal>,
239
+ }
240
+
241
+ #[napi]
242
+ impl DownloadHandle {
243
+ #[napi]
244
+ pub fn stop(&mut self) {
245
+ self.stop_flag.store(true, Ordering::Release);
246
+ self.task.abort();
247
+ }
248
+ }
249
+
250
+ #[napi]
251
+ impl StreamHandle {
252
+ #[napi]
253
+ pub fn stop(&mut self) {
254
+ if let Some(tx) = self.stop_tx.take() {
255
+ let _ = tx.try_send(());
256
+ }
257
+ }
258
+
259
+ #[napi(getter)]
260
+ pub fn sample_rate(&self) -> u32 {
261
+ self.sample_rate
262
+ }
263
+
264
+ #[napi(getter)]
265
+ pub fn channels(&self) -> u16 {
266
+ self.channels
267
+ }
268
+ }
269
+
270
+ struct ChannelSink {
271
+ tx: mpsc::Sender<Bytes>,
272
+ format: AudioFormat,
273
+ sample_rate: u32,
274
+ channels: u16,
275
+ start: Option<Instant>,
276
+ expected_elapsed: Duration,
277
+ first_chunk_logged: bool,
278
+ log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>,
279
+ event_tsfn: Option<ThreadsafeFunction<ConnectEvent, ErrorStrategy::Fatal>>,
280
+ scope: Option<String>,
281
+ device_id: Option<String>,
282
+ session_id: Option<String>,
283
+ track_id: Option<String>,
284
+ uri: Option<String>,
285
+ stream_start: Instant,
286
+ metric_sent: Arc<AtomicBool>,
287
+ first_chunk_flag: Arc<AtomicBool>,
288
+ last_pcm_at: Arc<AtomicU64>,
289
+ }
290
+
291
+ static LOG_OBSERVERS: OnceLock<Arc<Mutex<Vec<mpsc::Sender<LogEvent>>>>> = OnceLock::new();
292
+ static LOG_TSFNS: OnceLock<Arc<Mutex<Vec<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>>>> =
293
+ OnceLock::new();
294
+
295
+ fn log_observers() -> &'static Arc<Mutex<Vec<mpsc::Sender<LogEvent>>>> {
296
+ LOG_OBSERVERS.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
297
+ }
298
+
299
+ fn log_tsfns() -> &'static Arc<Mutex<Vec<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>>> {
300
+ LOG_TSFNS.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
301
+ }
302
+
303
+ struct NativeLogger;
304
+
305
+ impl Log for NativeLogger {
306
+ fn enabled(&self, _metadata: &LogMetadata<'_>) -> bool {
307
+ true
308
+ }
309
+
310
+ fn log(&self, record: &Record<'_>) {
311
+ let event = LogEvent {
312
+ level: record.level().to_string().to_lowercase(),
313
+ message: record.args().to_string(),
314
+ scope: Some(record.target().to_string()),
315
+ device_id: None,
316
+ session_id: None,
317
+ };
318
+ let mut observers = log_observers()
319
+ .lock()
320
+ .unwrap_or_else(|err| err.into_inner());
321
+ observers.retain(|sender| match sender.try_send(event.clone()) {
322
+ Ok(_) => true,
323
+ Err(mpsc::error::TrySendError::Full(_)) => true,
324
+ Err(mpsc::error::TrySendError::Closed(_)) => false,
325
+ });
326
+ drop(observers);
327
+ let tsfns = log_tsfns().lock().unwrap_or_else(|err| err.into_inner());
328
+ for tsfn in tsfns.iter() {
329
+ let _ = tsfn.call(event.clone(), ThreadsafeFunctionCallMode::NonBlocking);
330
+ }
331
+ }
332
+
333
+ fn flush(&self) {}
334
+ }
335
+
336
+ fn init_native_logger(log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>) {
337
+ if let Some(tsfn) = log_tsfn {
338
+ log_tsfns()
339
+ .lock()
340
+ .unwrap_or_else(|err| err.into_inner())
341
+ .push(tsfn);
342
+ }
343
+ if LOGGER_INIT.get().is_some() {
344
+ return;
345
+ }
346
+ if LOGGER_INIT.set(()).is_err() {
347
+ return;
348
+ }
349
+ let _ = log::set_boxed_logger(Box::new(NativeLogger));
350
+ log::set_max_level(LevelFilter::Debug);
351
+ }
352
+
353
+ #[napi]
354
+ pub fn set_log_level(level: String) -> Result<()> {
355
+ init_native_logger(None);
356
+ let normalized = level.trim().to_lowercase();
357
+ let filter = match normalized.as_str() {
358
+ "off" => LevelFilter::Off,
359
+ "error" => LevelFilter::Error,
360
+ "warn" | "warning" => LevelFilter::Warn,
361
+ "info" => LevelFilter::Info,
362
+ "debug" => LevelFilter::Debug,
363
+ "trace" => LevelFilter::Trace,
364
+ _ => return Err(Error::from_reason(format!("invalid log level: {}", level))),
365
+ };
366
+ log::set_max_level(filter);
367
+ Ok(())
368
+ }
369
+
370
+ fn subscribe_log_events() -> mpsc::Receiver<LogEvent> {
371
+ let (tx, rx) = mpsc::channel::<LogEvent>(256);
372
+ log_observers()
373
+ .lock()
374
+ .unwrap_or_else(|err| err.into_inner())
375
+ .push(tx);
376
+ rx
377
+ }
378
+
379
+ fn is_audio_key_error(event: &LogEvent) -> bool {
380
+ let message = event.message.to_lowercase();
381
+ message.contains("audio key")
382
+ || message.contains("audiokeyerror")
383
+ || message.contains("decryption key")
384
+ || message.contains("unable to load key")
385
+ || message.contains("unable to load decryption key")
386
+ }
387
+
388
+ fn is_decoder_error(event: &LogEvent) -> bool {
389
+ let message = event.message.to_lowercase();
390
+ message.contains("decoder error")
391
+ || message.contains("symphonia decoder error")
392
+ || message.contains("unable to read audio file")
393
+ || message.contains("end of stream")
394
+ }
395
+
396
+ impl ChannelSink {
397
+ fn new(
398
+ tx: mpsc::Sender<Bytes>,
399
+ format: AudioFormat,
400
+ sample_rate: u32,
401
+ channels: u16,
402
+ log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>,
403
+ event_tsfn: Option<ThreadsafeFunction<ConnectEvent, ErrorStrategy::Fatal>>,
404
+ scope: Option<String>,
405
+ device_id: Option<String>,
406
+ session_id: Option<String>,
407
+ track_id: Option<String>,
408
+ uri: Option<String>,
409
+ stream_start: Instant,
410
+ metric_sent: Arc<AtomicBool>,
411
+ first_chunk_flag: Arc<AtomicBool>,
412
+ last_pcm_at: Arc<AtomicU64>,
413
+ ) -> Self {
414
+ Self {
415
+ tx,
416
+ format,
417
+ sample_rate,
418
+ channels,
419
+ start: None,
420
+ expected_elapsed: Duration::from_millis(0),
421
+ first_chunk_logged: false,
422
+ log_tsfn,
423
+ event_tsfn,
424
+ scope,
425
+ device_id,
426
+ session_id,
427
+ track_id,
428
+ uri,
429
+ stream_start,
430
+ metric_sent,
431
+ first_chunk_flag,
432
+ last_pcm_at,
433
+ }
434
+ }
435
+ }
436
+
437
+ fn emit_log_ctx(
438
+ tsfn: &Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>>,
439
+ level: &str,
440
+ message: impl Into<String>,
441
+ scope: Option<&str>,
442
+ device_id: Option<&str>,
443
+ session_id: Option<&str>,
444
+ ) {
445
+ if let Some(tsfn) = tsfn {
446
+ let _ = tsfn.call(
447
+ LogEvent {
448
+ level: level.to_string(),
449
+ message: message.into(),
450
+ scope: scope.map(|val| val.to_string()),
451
+ device_id: device_id.map(|val| val.to_string()),
452
+ session_id: session_id.map(|val| val.to_string()),
453
+ },
454
+ ThreadsafeFunctionCallMode::NonBlocking,
455
+ );
456
+ }
457
+ }
458
+ impl Sink for ChannelSink {
459
+ fn start(&mut self) -> SinkResult<()> {
460
+ emit_log_ctx(
461
+ &self.log_tsfn,
462
+ "debug",
463
+ "sink start",
464
+ self.scope.as_deref(),
465
+ self.device_id.as_deref(),
466
+ self.session_id.as_deref(),
467
+ );
468
+ Ok(())
469
+ }
470
+
471
+ fn stop(&mut self) -> SinkResult<()> {
472
+ Ok(())
473
+ }
474
+
475
+ fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
476
+ let bytes: Bytes = match packet {
477
+ AudioPacket::Samples(samples) => match self.format {
478
+ AudioFormat::S16 => {
479
+ let s16: &[i16] = &converter.f64_to_s16(&samples);
480
+ Bytes::copy_from_slice(bytemuck::cast_slice(s16))
481
+ }
482
+ AudioFormat::F32 => {
483
+ let s32: &[f32] = &converter.f64_to_f32(&samples);
484
+ Bytes::copy_from_slice(bytemuck::cast_slice(s32))
485
+ }
486
+ AudioFormat::S24 => {
487
+ let s24: &[i32] = &converter.f64_to_s24(&samples);
488
+ Bytes::copy_from_slice(bytemuck::cast_slice(s24))
489
+ }
490
+ _ => {
491
+ let s16: &[i16] = &converter.f64_to_s16(&samples);
492
+ Bytes::copy_from_slice(bytemuck::cast_slice(s16))
493
+ }
494
+ },
495
+ AudioPacket::Raw(data) => Bytes::from(data),
496
+ };
497
+ if !self.first_chunk_logged && !bytes.is_empty() {
498
+ self.first_chunk_logged = true;
499
+ self.first_chunk_flag.store(true, Ordering::Release);
500
+ emit_log_ctx(
501
+ &self.log_tsfn,
502
+ "info",
503
+ format!(
504
+ "first pcm chunk: bytes={} sample_rate={} channels={}",
505
+ bytes.len(),
506
+ self.sample_rate,
507
+ self.channels
508
+ ),
509
+ self.scope.as_deref(),
510
+ self.device_id.as_deref(),
511
+ self.session_id.as_deref(),
512
+ );
513
+ eprintln!(
514
+ "[node-librespot] first pcm chunk: bytes={} sample_rate={} channels={}",
515
+ bytes.len(),
516
+ self.sample_rate,
517
+ self.channels
518
+ );
519
+ if let Some(tsfn_ev) = &self.event_tsfn {
520
+ if !self.metric_sent.swap(true, Ordering::AcqRel) {
521
+ let elapsed_ms = self.stream_start.elapsed().as_millis() as u64;
522
+ let elapsed_ms_u32 = elapsed_ms.min(u32::MAX as u64) as u32;
523
+ let payload = ConnectEvent {
524
+ r#type: "metric".into(),
525
+ device_id: self.device_id.clone(),
526
+ session_id: self.session_id.clone(),
527
+ track_id: self.track_id.clone(),
528
+ uri: self.uri.clone(),
529
+ title: None,
530
+ artist: None,
531
+ album: None,
532
+ duration_ms: None,
533
+ position_ms: None,
534
+ volume: None,
535
+ error_code: None,
536
+ error_message: None,
537
+ metric_name: Some("first_pcm_ms".into()),
538
+ metric_value_ms: Some(elapsed_ms_u32),
539
+ metric_message: None,
540
+ };
541
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
542
+ }
543
+ }
544
+ }
545
+ if !bytes.is_empty() {
546
+ let now_ms = SystemTime::now()
547
+ .duration_since(UNIX_EPOCH)
548
+ .unwrap_or_else(|_| Duration::from_secs(0))
549
+ .as_millis() as u64;
550
+ self.last_pcm_at.store(now_ms, Ordering::Release);
551
+ }
552
+ // Pacing: throttle to approximate realtime based on sample count.
553
+ let bytes_per_sample = match self.format {
554
+ AudioFormat::S24 => 3,
555
+ AudioFormat::S32 | AudioFormat::F32 => 4,
556
+ _ => 2,
557
+ };
558
+ let samples = bytes.len() / (bytes_per_sample * self.channels as usize);
559
+ let duration = Duration::from_secs_f64(samples as f64 / self.sample_rate as f64);
560
+ let start = self.start.get_or_insert_with(Instant::now);
561
+ self.expected_elapsed += duration;
562
+ let target = *start + self.expected_elapsed;
563
+ let now = Instant::now();
564
+ let sleep_dur = target.saturating_duration_since(now);
565
+
566
+ if !sleep_dur.is_zero() {
567
+ sleep(sleep_dur);
568
+ }
569
+
570
+ if self.tx.try_send(bytes).is_err() {
571
+ // Drop chunk if JS side is backpressured to avoid blocking the player thread.
572
+ }
573
+ Ok(())
574
+ }
575
+ }
576
+
577
+ #[napi]
578
+ impl LibrespotSession {
579
+ /// Stream a Spotify track or episode as PCM s16le and deliver chunks via a JS callback.
580
+ /// The callback receives Buffer chunks. Returns a handle with a `stop()` method.
581
+ #[napi]
582
+ pub fn stream_track(
583
+ &self,
584
+ opts: StreamTrackOpts,
585
+ on_chunk: JsFunction,
586
+ on_event: Option<JsFunction>,
587
+ on_log: Option<JsFunction>,
588
+ ) -> Result<StreamHandle> {
589
+ let uri = opts.uri;
590
+ if uri.is_empty() {
591
+ return Err(Error::from_reason("uri is required"));
592
+ }
593
+ let spotify_uri = SpotifyUri::from_uri(&uri)
594
+ .map_err(|e| Error::from_reason(format!("invalid spotify uri: {:?}", e)))?;
595
+
596
+ let (tx, mut rx) = mpsc::channel::<Bytes>(16);
597
+ let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
598
+
599
+ // Threadsafe function to deliver chunks to JS.
600
+ let tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal> = on_chunk
601
+ .create_threadsafe_function(0, |ctx: ThreadSafeCallContext<Bytes>| {
602
+ let env = ctx.env;
603
+ let buffer = ctx.value;
604
+ let js_buffer = env.create_buffer_with_data(buffer.to_vec())?;
605
+ Ok(vec![js_buffer.into_unknown()])
606
+ })?;
607
+
608
+ // Optional threadsafe function to deliver playback events.
609
+ let event_tsfn: Option<ThreadsafeFunction<ConnectEvent, ErrorStrategy::Fatal>> = on_event
610
+ .map(|f| {
611
+ f.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<ConnectEvent>| {
612
+ let env = ctx.env;
613
+ let val = ctx.value;
614
+ let mut obj = env.create_object()?;
615
+ obj.set_named_property("type", env.create_string(&val.r#type)?)?;
616
+ if let Some(device_id) = val.device_id {
617
+ obj.set_named_property("deviceId", env.create_string(&device_id)?)?;
618
+ }
619
+ if let Some(session_id) = val.session_id {
620
+ obj.set_named_property("sessionId", env.create_string(&session_id)?)?;
621
+ }
622
+ if let Some(tid) = val.track_id {
623
+ obj.set_named_property("trackId", env.create_string(&tid)?)?;
624
+ }
625
+ if let Some(uri) = val.uri {
626
+ obj.set_named_property("uri", env.create_string(&uri)?)?;
627
+ }
628
+ if let Some(pos) = val.position_ms {
629
+ obj.set_named_property("positionMs", env.create_uint32(pos))?;
630
+ }
631
+ if let Some(dur) = val.duration_ms {
632
+ obj.set_named_property("durationMs", env.create_uint32(dur))?;
633
+ }
634
+ if let Some(vol) = val.volume {
635
+ obj.set_named_property("volume", env.create_uint32(vol as u32))?;
636
+ }
637
+ if let Some(code) = val.error_code {
638
+ obj.set_named_property("errorCode", env.create_string(&code)?)?;
639
+ }
640
+ if let Some(message) = val.error_message {
641
+ obj.set_named_property("errorMessage", env.create_string(&message)?)?;
642
+ }
643
+ if let Some(metric_name) = val.metric_name {
644
+ obj.set_named_property("metricName", env.create_string(&metric_name)?)?;
645
+ }
646
+ if let Some(metric_value_ms) = val.metric_value_ms {
647
+ obj.set_named_property(
648
+ "metricValueMs",
649
+ env.create_uint32(metric_value_ms as u32),
650
+ )?;
651
+ }
652
+ if let Some(metric_message) = val.metric_message {
653
+ obj.set_named_property(
654
+ "metricMessage",
655
+ env.create_string(&metric_message)?,
656
+ )?;
657
+ }
658
+ Ok(vec![obj.into_unknown()])
659
+ })
660
+ })
661
+ .transpose()?;
662
+
663
+ let log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>> = on_log
664
+ .map(|f| {
665
+ f.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<LogEvent>| {
666
+ let env = ctx.env;
667
+ let val = ctx.value;
668
+ let mut obj = env.create_object()?;
669
+ obj.set_named_property("level", env.create_string(&val.level)?)?;
670
+ obj.set_named_property("message", env.create_string(&val.message)?)?;
671
+ if let Some(scope) = val.scope {
672
+ obj.set_named_property("scope", env.create_string(&scope)?)?;
673
+ }
674
+ if let Some(device_id) = val.device_id {
675
+ obj.set_named_property("deviceId", env.create_string(&device_id)?)?;
676
+ }
677
+ if let Some(session_id) = val.session_id {
678
+ obj.set_named_property("sessionId", env.create_string(&session_id)?)?;
679
+ }
680
+ Ok(vec![obj.into_unknown()])
681
+ })
682
+ })
683
+ .transpose()?;
684
+
685
+ init_native_logger(log_tsfn.clone());
686
+ let device_id = self.device_id.clone();
687
+ let session_id = next_session_id("stream");
688
+ emit_log_ctx(
689
+ &log_tsfn,
690
+ "info",
691
+ format!("stream_track start uri={}", uri),
692
+ Some("stream_track"),
693
+ Some(&device_id),
694
+ Some(&session_id),
695
+ );
696
+
697
+ // Spawn forwarder task to drain mpsc into TSFN.
698
+ runtime().spawn({
699
+ let tsfn = tsfn.clone();
700
+ async move {
701
+ while let Some(chunk) = rx.recv().await {
702
+ let _ = tsfn.call(chunk, ThreadsafeFunctionCallMode::NonBlocking);
703
+ }
704
+ }
705
+ });
706
+
707
+ let mut player_config = self.player_config.clone();
708
+ let session = self.session.clone();
709
+
710
+ // Build a playback backend that writes to our channel.
711
+ let log_tsfn_for_sink = log_tsfn.clone();
712
+ let event_tsfn_for_sink = event_tsfn.clone();
713
+ let log_tsfn_for_spawn = log_tsfn.clone();
714
+ let first_chunk_flag = Arc::new(AtomicBool::new(false));
715
+ let first_chunk_flag_for_sink = first_chunk_flag.clone();
716
+ let last_pcm_at = Arc::new(AtomicU64::new(0));
717
+ let last_pcm_at_for_sink = last_pcm_at.clone();
718
+ let stream_start = Instant::now();
719
+ let metric_sent = Arc::new(AtomicBool::new(false));
720
+ let metric_sent_for_sink = metric_sent.clone();
721
+ let error_sent = Arc::new(AtomicBool::new(false));
722
+ let decoder_metric_sent = Arc::new(AtomicBool::new(false));
723
+ let stop_flag = Arc::new(AtomicBool::new(false));
724
+ let track_id_for_events = spotify_uri.to_id();
725
+ let track_id_for_sink = track_id_for_events.clone();
726
+ let uri_for_sink = uri.clone();
727
+ let backend_factory = {
728
+ let tx_clone = tx.clone();
729
+ let device_id = device_id.clone();
730
+ let session_id = session_id.clone();
731
+ move || {
732
+ emit_log_ctx(
733
+ &log_tsfn_for_sink,
734
+ "debug",
735
+ "sink created",
736
+ Some("stream_track"),
737
+ Some(&device_id),
738
+ Some(&session_id),
739
+ );
740
+ let sink = ChannelSink::new(
741
+ tx_clone.clone(),
742
+ AudioFormat::S16,
743
+ 44100,
744
+ 2,
745
+ log_tsfn_for_sink.clone(),
746
+ event_tsfn_for_sink.clone(),
747
+ Some("stream_track".to_string()),
748
+ Some(device_id.clone()),
749
+ Some(session_id.clone()),
750
+ Some(track_id_for_sink.clone()),
751
+ Some(uri_for_sink.clone()),
752
+ stream_start,
753
+ metric_sent_for_sink.clone(),
754
+ first_chunk_flag_for_sink.clone(),
755
+ last_pcm_at_for_sink.clone(),
756
+ );
757
+ Box::new(sink) as Box<dyn Sink>
758
+ }
759
+ };
760
+
761
+ let start_position_ms = opts.start_position_ms.unwrap_or(0);
762
+ player_config.bitrate = match opts.bitrate {
763
+ Some(96) => Bitrate::Bitrate96,
764
+ Some(160) => Bitrate::Bitrate160,
765
+ _ => Bitrate::Bitrate320,
766
+ };
767
+
768
+ // Volume getter (no-op).
769
+ let volume_getter: Box<dyn VolumeGetter + Send> = Box::new(NoOpVolume);
770
+
771
+ // Spawn playback task.
772
+ let event_tsfn_clone = event_tsfn.clone();
773
+ let log_tsfn_clone = log_tsfn.clone();
774
+ let error_sent_for_spawn = error_sent.clone();
775
+ let error_sent_for_probe = error_sent.clone();
776
+ let error_sent_for_logs = error_sent.clone();
777
+ let decoder_metric_sent_for_logs = decoder_metric_sent.clone();
778
+ let stop_flag_for_logs = stop_flag.clone();
779
+ let uri_for_events = uri.clone();
780
+ let device_id_for_events = device_id.clone();
781
+ let session_id_for_events = session_id.clone();
782
+ let stop_flag_for_events = stop_flag.clone();
783
+ let stop_flag_for_health = stop_flag.clone();
784
+ let last_pcm_for_health = last_pcm_at.clone();
785
+ let mut log_rx = subscribe_log_events();
786
+ let event_tsfn_for_logs = event_tsfn.clone();
787
+ let uri_for_logs = uri.clone();
788
+ let track_for_logs = track_id_for_events.clone();
789
+ let device_id_for_logs = device_id.clone();
790
+ let session_id_for_logs = session_id.clone();
791
+ runtime().spawn(async move {
792
+ while let Some(event) = log_rx.recv().await {
793
+ if stop_flag_for_logs.load(Ordering::Acquire) {
794
+ break;
795
+ }
796
+ if is_decoder_error(&event) {
797
+ if let Some(tsfn_ev) = &event_tsfn_for_logs {
798
+ if !decoder_metric_sent_for_logs.swap(true, Ordering::AcqRel) {
799
+ let payload = ConnectEvent {
800
+ r#type: "metric".into(),
801
+ device_id: Some(device_id_for_logs.clone()),
802
+ session_id: Some(session_id_for_logs.clone()),
803
+ track_id: Some(track_for_logs.clone()),
804
+ uri: Some(uri_for_logs.clone()),
805
+ title: None,
806
+ artist: None,
807
+ album: None,
808
+ duration_ms: None,
809
+ position_ms: None,
810
+ volume: None,
811
+ error_code: None,
812
+ error_message: None,
813
+ metric_name: Some("decode_error".into()),
814
+ metric_value_ms: None,
815
+ metric_message: Some(event.message.clone()),
816
+ };
817
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
818
+ }
819
+ }
820
+ }
821
+ if error_sent_for_logs.load(Ordering::Acquire) {
822
+ continue;
823
+ }
824
+ if !is_audio_key_error(&event) {
825
+ continue;
826
+ }
827
+ if error_sent_for_logs.swap(true, Ordering::AcqRel) {
828
+ continue;
829
+ }
830
+ if let Some(tsfn_ev) = &event_tsfn_for_logs {
831
+ let payload = ConnectEvent {
832
+ r#type: "error".into(),
833
+ device_id: Some(device_id_for_logs.clone()),
834
+ session_id: Some(session_id_for_logs.clone()),
835
+ track_id: Some(track_for_logs.clone()),
836
+ uri: Some(uri_for_logs.clone()),
837
+ title: None,
838
+ artist: None,
839
+ album: None,
840
+ duration_ms: None,
841
+ position_ms: None,
842
+ volume: None,
843
+ error_code: Some("audio_key_error".into()),
844
+ error_message: Some(event.message.clone()),
845
+ metric_name: None,
846
+ metric_value_ms: None,
847
+ metric_message: None,
848
+ };
849
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
850
+ }
851
+ }
852
+ });
853
+
854
+ runtime().spawn(async move {
855
+ let player = Player::new(player_config, session, volume_getter, backend_factory);
856
+ let mut event_rx = player.get_player_event_channel();
857
+ player.load(spotify_uri, true, start_position_ms);
858
+ player.play();
859
+ emit_log_ctx(
860
+ &log_tsfn_clone,
861
+ "info",
862
+ "player.load + player.play invoked",
863
+ Some("stream_track"),
864
+ Some(&device_id_for_events),
865
+ Some(&session_id_for_events),
866
+ );
867
+
868
+ let first_chunk_probe = first_chunk_flag.clone();
869
+ let log_for_probe = log_tsfn_for_spawn.clone();
870
+ let event_for_probe = event_tsfn_clone.clone();
871
+ let uri_for_probe = uri_for_events.clone();
872
+ let track_for_probe = track_id_for_events.clone();
873
+ let device_id_for_probe = device_id_for_events.clone();
874
+ let session_id_for_probe = session_id_for_events.clone();
875
+ runtime().spawn(async move {
876
+ tokio::time::sleep(Duration::from_millis(1500)).await;
877
+ if !first_chunk_probe.load(Ordering::Acquire)
878
+ && !error_sent_for_probe.swap(true, Ordering::AcqRel)
879
+ {
880
+ emit_log_ctx(
881
+ &log_for_probe,
882
+ "warn",
883
+ "no pcm after 1500ms",
884
+ Some("stream_track"),
885
+ Some(&device_id_for_probe),
886
+ Some(&session_id_for_probe),
887
+ );
888
+ if let Some(tsfn_ev) = &event_for_probe {
889
+ let payload = ConnectEvent {
890
+ r#type: "error".into(),
891
+ device_id: Some(device_id_for_probe.clone()),
892
+ session_id: Some(session_id_for_probe.clone()),
893
+ track_id: Some(track_for_probe.clone()),
894
+ uri: Some(uri_for_probe.clone()),
895
+ title: None,
896
+ artist: None,
897
+ album: None,
898
+ duration_ms: None,
899
+ position_ms: None,
900
+ volume: None,
901
+ error_code: Some("no_pcm".into()),
902
+ error_message: Some("no pcm after 1500ms".into()),
903
+ metric_name: None,
904
+ metric_value_ms: None,
905
+ metric_message: None,
906
+ };
907
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
908
+ }
909
+ }
910
+ });
911
+ if let Some(tsfn_ev) = event_tsfn_clone.clone() {
912
+ let uri_for_health = uri_for_events.clone();
913
+ let track_for_health = track_id_for_events.clone();
914
+ let device_id_for_health = device_id_for_events.clone();
915
+ let session_id_for_health = session_id_for_events.clone();
916
+ runtime().spawn(async move {
917
+ let mut stall_reported = false;
918
+ loop {
919
+ tokio::time::sleep(Duration::from_secs(5)).await;
920
+ if stop_flag_for_health.load(Ordering::Acquire) {
921
+ break;
922
+ }
923
+ let last_ms = last_pcm_for_health.load(Ordering::Acquire);
924
+ let now_ms = SystemTime::now()
925
+ .duration_since(UNIX_EPOCH)
926
+ .unwrap_or_else(|_| Duration::from_secs(0))
927
+ .as_millis() as u64;
928
+ let stall_ms = now_ms.saturating_sub(last_ms);
929
+ let (code, message) = if last_ms == 0 {
930
+ ("pcm_missing", "no pcm received yet")
931
+ } else if stall_ms > 2000 {
932
+ ("pcm_stalled", "pcm stalled")
933
+ } else {
934
+ ("pcm_ok", "pcm flowing")
935
+ };
936
+ if code == "pcm_ok" {
937
+ stall_reported = false;
938
+ }
939
+ if code == "pcm_stalled" && !stall_reported {
940
+ stall_reported = true;
941
+ let stall_ms_u32 = stall_ms.min(u32::MAX as u64) as u32;
942
+ let metric_payload = ConnectEvent {
943
+ r#type: "metric".into(),
944
+ device_id: Some(device_id_for_health.clone()),
945
+ session_id: Some(session_id_for_health.clone()),
946
+ track_id: Some(track_for_health.clone()),
947
+ uri: Some(uri_for_health.clone()),
948
+ title: None,
949
+ artist: None,
950
+ album: None,
951
+ duration_ms: None,
952
+ position_ms: None,
953
+ volume: None,
954
+ error_code: None,
955
+ error_message: None,
956
+ metric_name: Some("buffer_stall_ms".into()),
957
+ metric_value_ms: Some(stall_ms_u32),
958
+ metric_message: Some("pcm stalled".into()),
959
+ };
960
+ let _ = tsfn_ev.call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
961
+ }
962
+ let payload = ConnectEvent {
963
+ r#type: "health".into(),
964
+ device_id: Some(device_id_for_health.clone()),
965
+ session_id: Some(session_id_for_health.clone()),
966
+ track_id: Some(track_for_health.clone()),
967
+ uri: Some(uri_for_health.clone()),
968
+ title: None,
969
+ artist: None,
970
+ album: None,
971
+ duration_ms: None,
972
+ position_ms: None,
973
+ volume: None,
974
+ error_code: Some(code.into()),
975
+ error_message: Some(message.into()),
976
+ metric_name: None,
977
+ metric_value_ms: None,
978
+ metric_message: None,
979
+ };
980
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
981
+ }
982
+ });
983
+ }
984
+
985
+ let mut last_position_ms: Option<u32> = None;
986
+ let mut last_duration_ms: Option<u32> = None;
987
+ let mut saw_playing = false;
988
+ let mut first_event_logged = false;
989
+ let mut event_count: u32 = 0;
990
+ let mut sent_unavailable = false;
991
+ loop {
992
+ tokio::select! {
993
+ _ = stop_rx.recv() => {
994
+ player.stop();
995
+ emit_log_ctx(
996
+ &log_tsfn_clone,
997
+ "info",
998
+ "stop received; player.stop()",
999
+ Some("stream_track"),
1000
+ Some(&device_id_for_events),
1001
+ Some(&session_id_for_events),
1002
+ );
1003
+ stop_flag_for_events.store(true, Ordering::Release);
1004
+ break;
1005
+ }
1006
+ Some(ev) = event_rx.recv(), if opts.emit_events.unwrap_or(true) => {
1007
+ let event_name = match &ev {
1008
+ PlayerEvent::Playing { .. } => "playing",
1009
+ PlayerEvent::Paused { .. } => "paused",
1010
+ PlayerEvent::Loading { .. } => "loading",
1011
+ PlayerEvent::Stopped { .. } => "stopped",
1012
+ PlayerEvent::EndOfTrack { .. } => "end_of_track",
1013
+ PlayerEvent::Unavailable { .. } => "unavailable",
1014
+ PlayerEvent::Preloading { .. } => "preloading",
1015
+ PlayerEvent::TimeToPreloadNextTrack { .. } => "time_to_preload",
1016
+ PlayerEvent::VolumeChanged { .. } => "volume",
1017
+ PlayerEvent::PositionCorrection { .. } => "position_correction",
1018
+ PlayerEvent::PlayRequestIdChanged { .. } => "play_request_id",
1019
+ _ => "other",
1020
+ };
1021
+ if !first_event_logged {
1022
+ first_event_logged = true;
1023
+ emit_log_ctx(
1024
+ &log_tsfn_clone,
1025
+ "debug",
1026
+ format!("first player event: {}", event_name),
1027
+ Some("stream_track"),
1028
+ Some(&device_id_for_events),
1029
+ Some(&session_id_for_events),
1030
+ );
1031
+ }
1032
+ event_count += 1;
1033
+ if event_count <= 5 {
1034
+ emit_log_ctx(
1035
+ &log_tsfn_clone,
1036
+ "debug",
1037
+ format!("player event {}", event_name),
1038
+ Some("stream_track"),
1039
+ Some(&device_id_for_events),
1040
+ Some(&session_id_for_events),
1041
+ );
1042
+ }
1043
+ if event_name == "unavailable" && !sent_unavailable {
1044
+ sent_unavailable = true;
1045
+ emit_log_ctx(
1046
+ &log_tsfn_clone,
1047
+ "warn",
1048
+ "player reported track unavailable",
1049
+ Some("stream_track"),
1050
+ Some(&device_id_for_events),
1051
+ Some(&session_id_for_events),
1052
+ );
1053
+ if !error_sent_for_spawn.swap(true, Ordering::AcqRel) {
1054
+ if let Some(tsfn_ev) = &event_tsfn_clone {
1055
+ let payload = ConnectEvent {
1056
+ r#type: "error".into(),
1057
+ device_id: Some(device_id_for_events.clone()),
1058
+ session_id: Some(session_id_for_events.clone()),
1059
+ track_id: Some(track_id_for_events.clone()),
1060
+ uri: Some(uri_for_events.clone()),
1061
+ title: None,
1062
+ artist: None,
1063
+ album: None,
1064
+ duration_ms: None,
1065
+ position_ms: None,
1066
+ volume: None,
1067
+ error_code: Some("unavailable".into()),
1068
+ error_message: Some("track unavailable".into()),
1069
+ metric_name: None,
1070
+ metric_value_ms: None,
1071
+ metric_message: None,
1072
+ };
1073
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1074
+ }
1075
+ }
1076
+ }
1077
+ if let Some(tsfn_ev) = &event_tsfn_clone {
1078
+ let payload = match ev {
1079
+ PlayerEvent::Playing { track_id, position_ms, .. } => ConnectEvent {
1080
+ r#type: "playing".into(),
1081
+ device_id: Some(device_id_for_events.clone()),
1082
+ session_id: Some(session_id_for_events.clone()),
1083
+ track_id: Some(track_id.to_id()),
1084
+ uri: Some(track_id.to_uri()),
1085
+ title: None,
1086
+ artist: None,
1087
+ album: None,
1088
+ position_ms: Some(position_ms),
1089
+ duration_ms: None,
1090
+ volume: None,
1091
+ error_code: None,
1092
+ error_message: None,
1093
+ metric_name: None,
1094
+ metric_value_ms: None,
1095
+ metric_message: None,
1096
+ },
1097
+ PlayerEvent::Paused { track_id, position_ms, .. } => ConnectEvent {
1098
+ r#type: "paused".into(),
1099
+ device_id: Some(device_id_for_events.clone()),
1100
+ session_id: Some(session_id_for_events.clone()),
1101
+ track_id: Some(track_id.to_id()),
1102
+ uri: Some(track_id.to_uri()),
1103
+ title: None,
1104
+ artist: None,
1105
+ album: None,
1106
+ position_ms: Some(position_ms),
1107
+ duration_ms: None,
1108
+ volume: None,
1109
+ error_code: None,
1110
+ error_message: None,
1111
+ metric_name: None,
1112
+ metric_value_ms: None,
1113
+ metric_message: None,
1114
+ },
1115
+ PlayerEvent::Loading { track_id, position_ms, .. } => ConnectEvent {
1116
+ r#type: "loading".into(),
1117
+ device_id: Some(device_id_for_events.clone()),
1118
+ session_id: Some(session_id_for_events.clone()),
1119
+ track_id: Some(track_id.to_id()),
1120
+ uri: Some(track_id.to_uri()),
1121
+ title: None,
1122
+ artist: None,
1123
+ album: None,
1124
+ position_ms: Some(position_ms),
1125
+ duration_ms: None,
1126
+ volume: None,
1127
+ error_code: None,
1128
+ error_message: None,
1129
+ metric_name: None,
1130
+ metric_value_ms: None,
1131
+ metric_message: None,
1132
+ },
1133
+ PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1134
+ r#type: "stopped".into(),
1135
+ device_id: Some(device_id_for_events.clone()),
1136
+ session_id: Some(session_id_for_events.clone()),
1137
+ track_id: Some(track_id.to_id()),
1138
+ uri: Some(track_id.to_uri()),
1139
+ title: None,
1140
+ artist: None,
1141
+ album: None,
1142
+ position_ms: None,
1143
+ duration_ms: None,
1144
+ volume: None,
1145
+ error_code: None,
1146
+ error_message: None,
1147
+ metric_name: None,
1148
+ metric_value_ms: None,
1149
+ metric_message: None,
1150
+ },
1151
+ PlayerEvent::EndOfTrack { track_id, .. } => {
1152
+ if !saw_playing || last_duration_ms.is_none() {
1153
+ if !error_sent_for_spawn.swap(true, Ordering::AcqRel) {
1154
+ let fallback_id = track_id.to_id();
1155
+ let payload = ConnectEvent {
1156
+ r#type: "error".into(),
1157
+ device_id: Some(device_id_for_events.clone()),
1158
+ session_id: Some(session_id_for_events.clone()),
1159
+ track_id: Some(fallback_id),
1160
+ uri: Some(track_id.to_uri()),
1161
+ title: None,
1162
+ artist: None,
1163
+ album: None,
1164
+ duration_ms: None,
1165
+ position_ms: None,
1166
+ volume: None,
1167
+ error_code: Some("end_of_track".into()),
1168
+ error_message: Some("end_of_track before pcm".into()),
1169
+ metric_name: None,
1170
+ metric_value_ms: None,
1171
+ metric_message: None,
1172
+ };
1173
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1174
+ }
1175
+ continue;
1176
+ }
1177
+ ConnectEvent {
1178
+ r#type: "end_of_track".into(),
1179
+ device_id: Some(device_id_for_events.clone()),
1180
+ session_id: Some(session_id_for_events.clone()),
1181
+ track_id: Some(track_id.to_id()),
1182
+ uri: Some(track_id.to_uri()),
1183
+ title: None,
1184
+ artist: None,
1185
+ album: None,
1186
+ position_ms: last_position_ms,
1187
+ duration_ms: last_duration_ms,
1188
+ volume: None,
1189
+ error_code: None,
1190
+ error_message: None,
1191
+ metric_name: None,
1192
+ metric_value_ms: None,
1193
+ metric_message: None,
1194
+ }
1195
+ }
1196
+ PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
1197
+ r#type: "unavailable".into(),
1198
+ device_id: Some(device_id_for_events.clone()),
1199
+ session_id: Some(session_id_for_events.clone()),
1200
+ track_id: Some(track_id.to_id()),
1201
+ uri: Some(track_id.to_uri()),
1202
+ title: None,
1203
+ artist: None,
1204
+ album: None,
1205
+ position_ms: None,
1206
+ duration_ms: None,
1207
+ volume: None,
1208
+ error_code: None,
1209
+ error_message: None,
1210
+ metric_name: None,
1211
+ metric_value_ms: None,
1212
+ metric_message: None,
1213
+ },
1214
+ PlayerEvent::VolumeChanged { volume } => ConnectEvent {
1215
+ r#type: "volume".into(),
1216
+ device_id: Some(device_id_for_events.clone()),
1217
+ session_id: Some(session_id_for_events.clone()),
1218
+ track_id: None,
1219
+ uri: None,
1220
+ title: None,
1221
+ artist: None,
1222
+ album: None,
1223
+ position_ms: None,
1224
+ duration_ms: None,
1225
+ volume: Some(volume),
1226
+ error_code: None,
1227
+ error_message: None,
1228
+ metric_name: None,
1229
+ metric_value_ms: None,
1230
+ metric_message: None,
1231
+ },
1232
+ PlayerEvent::PositionCorrection { track_id, position_ms, .. } => ConnectEvent {
1233
+ r#type: "position_correction".into(),
1234
+ device_id: Some(device_id_for_events.clone()),
1235
+ session_id: Some(session_id_for_events.clone()),
1236
+ track_id: Some(track_id.to_id()),
1237
+ uri: Some(track_id.to_uri()),
1238
+ title: None,
1239
+ artist: None,
1240
+ album: None,
1241
+ position_ms: Some(position_ms),
1242
+ duration_ms: None,
1243
+ volume: None,
1244
+ error_code: None,
1245
+ error_message: None,
1246
+ metric_name: None,
1247
+ metric_value_ms: None,
1248
+ metric_message: None,
1249
+ },
1250
+ _ => continue,
1251
+ };
1252
+ match payload.r#type.as_str() {
1253
+ "playing" | "paused" => {
1254
+ saw_playing = true;
1255
+ if let Some(pos) = payload.position_ms {
1256
+ last_position_ms = Some(pos);
1257
+ }
1258
+ if let Some(dur) = payload.duration_ms {
1259
+ last_duration_ms = Some(dur);
1260
+ }
1261
+ }
1262
+ _ => {}
1263
+ }
1264
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1265
+ }
1266
+ }
1267
+ }
1268
+ }
1269
+ stop_flag_for_events.store(true, Ordering::Release);
1270
+ });
1271
+
1272
+ Ok(StreamHandle {
1273
+ stop_tx: Some(stop_tx),
1274
+ tsfn,
1275
+ sample_rate: 44100,
1276
+ channels: 2,
1277
+ event_tsfn,
1278
+ })
1279
+ }
1280
+
1281
+ /// Download (stream) raw decrypted audio bytes for a track/episode.
1282
+ #[napi]
1283
+ pub fn download_track(
1284
+ &self,
1285
+ opts: DownloadTrackOpts,
1286
+ on_chunk: JsFunction,
1287
+ on_log: Option<JsFunction>,
1288
+ ) -> Result<DownloadHandle> {
1289
+ let uri = opts.uri.clone();
1290
+ if uri.is_empty() {
1291
+ return Err(Error::from_reason("uri is required"));
1292
+ }
1293
+ let spotify_uri = SpotifyUri::from_uri(&uri)
1294
+ .map_err(|e| Error::from_reason(format!("invalid spotify uri: {:?}", e)))?;
1295
+
1296
+ let (tx, mut rx) = mpsc::channel::<Bytes>(16);
1297
+ let stop_flag = Arc::new(AtomicBool::new(false));
1298
+
1299
+ let tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal> = on_chunk
1300
+ .create_threadsafe_function(0, |ctx: ThreadSafeCallContext<Bytes>| {
1301
+ let env = ctx.env;
1302
+ let buffer = ctx.value;
1303
+ let js_buffer = env.create_buffer_with_data(buffer.to_vec())?;
1304
+ Ok(vec![js_buffer.into_unknown()])
1305
+ })?;
1306
+
1307
+ let log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>> = on_log
1308
+ .map(|f| {
1309
+ f.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<LogEvent>| {
1310
+ let env = ctx.env;
1311
+ let val = ctx.value;
1312
+ let mut obj = env.create_object()?;
1313
+ obj.set_named_property("level", env.create_string(&val.level)?)?;
1314
+ obj.set_named_property("message", env.create_string(&val.message)?)?;
1315
+ if let Some(scope) = val.scope {
1316
+ obj.set_named_property("scope", env.create_string(&scope)?)?;
1317
+ }
1318
+ if let Some(device_id) = val.device_id {
1319
+ obj.set_named_property("deviceId", env.create_string(&device_id)?)?;
1320
+ }
1321
+ if let Some(session_id) = val.session_id {
1322
+ obj.set_named_property("sessionId", env.create_string(&session_id)?)?;
1323
+ }
1324
+ Ok(vec![obj.into_unknown()])
1325
+ })
1326
+ })
1327
+ .transpose()?;
1328
+
1329
+ init_native_logger(log_tsfn.clone());
1330
+ let device_id = self.device_id.clone();
1331
+ let session_id = next_session_id("download");
1332
+ emit_log_ctx(
1333
+ &log_tsfn,
1334
+ "info",
1335
+ format!("download_track start uri={}", uri),
1336
+ Some("download_track"),
1337
+ Some(&device_id),
1338
+ Some(&session_id),
1339
+ );
1340
+
1341
+ runtime().spawn({
1342
+ let tsfn = tsfn.clone();
1343
+ let stop_flag = stop_flag.clone();
1344
+ async move {
1345
+ while let Some(chunk) = rx.recv().await {
1346
+ if stop_flag.load(Ordering::Acquire) {
1347
+ break;
1348
+ }
1349
+ let _ = tsfn.call(chunk, ThreadsafeFunctionCallMode::NonBlocking);
1350
+ }
1351
+ }
1352
+ });
1353
+
1354
+ let session = self.session.clone();
1355
+ let bitrate_pref = opts.bitrate;
1356
+
1357
+ let track_id: SpotifyId = (&spotify_uri)
1358
+ .try_into()
1359
+ .map_err(|e| Error::from_reason(format!("invalid spotify id: {e:?}")))?;
1360
+
1361
+ let (encrypted_file, key) = runtime()
1362
+ .block_on(async {
1363
+ let audio_item = AudioItem::get_file(&session, spotify_uri.clone())
1364
+ .await
1365
+ .map_err(|e| Error::from_reason(format!("failed to load audio item: {e:?}")))?;
1366
+
1367
+ let select_format =
1368
+ |files: &AudioFiles, bitrate: Option<u32>| -> Option<(AudioFileFormat, FileId)> {
1369
+ let prefer = match bitrate {
1370
+ Some(96) => {
1371
+ vec![AudioFileFormat::OGG_VORBIS_96, AudioFileFormat::MP3_96]
1372
+ }
1373
+ Some(160) => {
1374
+ vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
1375
+ }
1376
+ _ => vec![
1377
+ AudioFileFormat::OGG_VORBIS_320,
1378
+ AudioFileFormat::MP3_320,
1379
+ AudioFileFormat::MP3_256,
1380
+ ],
1381
+ };
1382
+ for f in prefer {
1383
+ if let Some(id) = files.get(&f) {
1384
+ return Some((f, *id));
1385
+ }
1386
+ }
1387
+ files.iter().next().map(|(f, id)| (*f, *id))
1388
+ };
1389
+
1390
+ let (format, file_id) = select_format(&audio_item.files, bitrate_pref)
1391
+ .ok_or_else(|| Error::from_reason("no audio files available"))?;
1392
+
1393
+ let bytes_per_second = stream_data_rate(format)
1394
+ .ok_or_else(|| Error::from_reason("unable to compute data rate"))?;
1395
+
1396
+ let encrypted_file = AudioFile::open(&session, file_id, bytes_per_second)
1397
+ .await
1398
+ .map_err(|e| {
1399
+ Error::from_reason(format!("failed to open audio file: {e:?}"))
1400
+ })?;
1401
+
1402
+ let key = match session.audio_key().request(track_id, file_id).await {
1403
+ Ok(key) => Some(key),
1404
+ Err(e) => {
1405
+ emit_log_ctx(
1406
+ &log_tsfn,
1407
+ "warn",
1408
+ format!("audio key unavailable, continuing without decryption: {e:?}"),
1409
+ Some("download_track"),
1410
+ None,
1411
+ None,
1412
+ );
1413
+ None
1414
+ }
1415
+ };
1416
+
1417
+ Ok::<_, Error>((encrypted_file, key))
1418
+ })?;
1419
+
1420
+ let stop_flag_clone = stop_flag.clone();
1421
+ let log_tsfn_clone = log_tsfn.clone();
1422
+
1423
+ let task = runtime().spawn(async move {
1424
+ let mut decrypted = AudioDecrypt::new(key, encrypted_file);
1425
+ let mut buf = vec![0u8; 32 * 1024];
1426
+ loop {
1427
+ if stop_flag_clone.load(Ordering::Acquire) {
1428
+ break;
1429
+ }
1430
+ match decrypted.read(&mut buf) {
1431
+ Ok(0) => break,
1432
+ Ok(n) => {
1433
+ if tx.send(Bytes::copy_from_slice(&buf[..n])).await.is_err() {
1434
+ break;
1435
+ }
1436
+ }
1437
+ Err(e) => {
1438
+ emit_log_ctx(
1439
+ &log_tsfn_clone,
1440
+ "error",
1441
+ format!("read error: {e:?}"),
1442
+ Some("download_track"),
1443
+ None,
1444
+ None,
1445
+ );
1446
+ break;
1447
+ }
1448
+ }
1449
+ }
1450
+ });
1451
+
1452
+ Ok(DownloadHandle {
1453
+ stop_flag,
1454
+ task,
1455
+ tsfn,
1456
+ })
1457
+ }
1458
+
1459
+ #[napi]
1460
+ pub async fn close(&self) -> Result<()> {
1461
+ Ok(())
1462
+ }
1463
+ }
1464
+
1465
+ /// Create a session using a Web API access token (client id optional via opts or env).
1466
+ #[napi]
1467
+ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession> {
1468
+ let access_token = opts
1469
+ .access_token
1470
+ .clone()
1471
+ .unwrap_or_default()
1472
+ .trim()
1473
+ .to_string();
1474
+ if access_token.is_empty() {
1475
+ return Err(Error::from_reason(
1476
+ "access token is required; obtain a user token via PKCE/Web API",
1477
+ ));
1478
+ }
1479
+ let credentials = Credentials::with_access_token(access_token);
1480
+
1481
+ let mut session_config = SessionConfig::default();
1482
+ let mut device_id = opts.device_name.unwrap_or_else(|| "librespot".to_string());
1483
+ if device_id.trim().is_empty() {
1484
+ device_id = "librespot".to_string();
1485
+ }
1486
+ session_config.device_id = device_id.clone();
1487
+ if let Some(client_id) = opts.client_id {
1488
+ if !client_id.trim().is_empty() {
1489
+ session_config.client_id = client_id;
1490
+ }
1491
+ }
1492
+
1493
+ let session = Session::new(session_config, None);
1494
+ session
1495
+ .connect(credentials, false)
1496
+ .await
1497
+ .map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
1498
+
1499
+ let player_config = PlayerConfig::default();
1500
+
1501
+ Ok(LibrespotSession {
1502
+ session,
1503
+ player_config,
1504
+ device_id,
1505
+ })
1506
+ }
1507
+
1508
+ /// Internal helper to start a Spotify Connect device using provided credentials.
1509
+ /// Accepts credentials (typically created from an OAuth access token) and is shared by the
1510
+ /// token-based public entrypoint.
1511
+ fn start_connect_device_inner(
1512
+ credentials_path: String,
1513
+ name: String,
1514
+ device_id: String,
1515
+ on_chunk: JsFunction,
1516
+ on_event: Option<JsFunction>,
1517
+ on_log: Option<JsFunction>,
1518
+ ) -> Result<ConnectHandle> {
1519
+ let tsfn: ThreadsafeFunction<Bytes, ErrorStrategy::Fatal> = on_chunk
1520
+ .create_threadsafe_function(0, |ctx: ThreadSafeCallContext<Bytes>| {
1521
+ let env = ctx.env;
1522
+ let buffer = ctx.value;
1523
+ let js_buffer = env.create_buffer_with_data(buffer.to_vec())?;
1524
+ Ok(vec![js_buffer.into_unknown()])
1525
+ })?;
1526
+ let event_tsfn: Option<ThreadsafeFunction<ConnectEvent, ErrorStrategy::Fatal>> = on_event
1527
+ .map(|f| {
1528
+ f.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<ConnectEvent>| {
1529
+ let env = ctx.env;
1530
+ let val = ctx.value;
1531
+ // Serialize ConnectEvent into a JS object.
1532
+ let mut obj = env.create_object()?;
1533
+ obj.set_named_property("type", env.create_string(&val.r#type)?)?;
1534
+ if let Some(device_id) = val.device_id {
1535
+ obj.set_named_property("deviceId", env.create_string(&device_id)?)?;
1536
+ }
1537
+ if let Some(session_id) = val.session_id {
1538
+ obj.set_named_property("sessionId", env.create_string(&session_id)?)?;
1539
+ }
1540
+ if let Some(tid) = val.track_id {
1541
+ obj.set_named_property("trackId", env.create_string(&tid)?)?;
1542
+ }
1543
+ if let Some(uri) = val.uri {
1544
+ obj.set_named_property("uri", env.create_string(&uri)?)?;
1545
+ }
1546
+ if let Some(title) = val.title {
1547
+ obj.set_named_property("title", env.create_string(&title)?)?;
1548
+ }
1549
+ if let Some(artist) = val.artist {
1550
+ obj.set_named_property("artist", env.create_string(&artist)?)?;
1551
+ }
1552
+ if let Some(album) = val.album {
1553
+ obj.set_named_property("album", env.create_string(&album)?)?;
1554
+ }
1555
+ if let Some(pos) = val.position_ms {
1556
+ obj.set_named_property("positionMs", env.create_uint32(pos))?;
1557
+ }
1558
+ if let Some(dur) = val.duration_ms {
1559
+ obj.set_named_property("durationMs", env.create_uint32(dur))?;
1560
+ }
1561
+ if let Some(vol) = val.volume {
1562
+ obj.set_named_property("volume", env.create_uint32(vol as u32))?;
1563
+ }
1564
+ if let Some(code) = val.error_code {
1565
+ obj.set_named_property("errorCode", env.create_string(&code)?)?;
1566
+ }
1567
+ if let Some(message) = val.error_message {
1568
+ obj.set_named_property("errorMessage", env.create_string(&message)?)?;
1569
+ }
1570
+ if let Some(metric_name) = val.metric_name {
1571
+ obj.set_named_property("metricName", env.create_string(&metric_name)?)?;
1572
+ }
1573
+ if let Some(metric_value_ms) = val.metric_value_ms {
1574
+ obj.set_named_property(
1575
+ "metricValueMs",
1576
+ env.create_uint32(metric_value_ms as u32),
1577
+ )?;
1578
+ }
1579
+ if let Some(metric_message) = val.metric_message {
1580
+ obj.set_named_property("metricMessage", env.create_string(&metric_message)?)?;
1581
+ }
1582
+ Ok(vec![obj.into_unknown()])
1583
+ })
1584
+ })
1585
+ .transpose()?;
1586
+
1587
+ let log_tsfn: Option<ThreadsafeFunction<LogEvent, ErrorStrategy::Fatal>> = on_log
1588
+ .map(|f| {
1589
+ f.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<LogEvent>| {
1590
+ let env = ctx.env;
1591
+ let val = ctx.value;
1592
+ let mut obj = env.create_object()?;
1593
+ obj.set_named_property("level", env.create_string(&val.level)?)?;
1594
+ obj.set_named_property("message", env.create_string(&val.message)?)?;
1595
+ if let Some(scope) = val.scope {
1596
+ obj.set_named_property("scope", env.create_string(&scope)?)?;
1597
+ }
1598
+ if let Some(device_id) = val.device_id {
1599
+ obj.set_named_property("deviceId", env.create_string(&device_id)?)?;
1600
+ }
1601
+ if let Some(session_id) = val.session_id {
1602
+ obj.set_named_property("sessionId", env.create_string(&session_id)?)?;
1603
+ }
1604
+ Ok(vec![obj.into_unknown()])
1605
+ })
1606
+ })
1607
+ .transpose()?;
1608
+
1609
+ init_native_logger(log_tsfn.clone());
1610
+ let session_id = next_session_id("connect");
1611
+ let stop_flag = Arc::new(AtomicBool::new(false));
1612
+ let stop_flag_for_block = stop_flag.clone();
1613
+ runtime().block_on(async move {
1614
+ emit_log_ctx(
1615
+ &log_tsfn,
1616
+ "info",
1617
+ "connect host start",
1618
+ Some("connect_host"),
1619
+ Some(&device_id),
1620
+ Some(&session_id),
1621
+ );
1622
+ let credentials: Credentials = if Path::new(&credentials_path).exists() {
1623
+ let mut file =
1624
+ File::open(&credentials_path).map_err(|e| Error::from_reason(format!("{e}")))?;
1625
+ let mut buf = String::new();
1626
+ file.read_to_string(&mut buf)
1627
+ .map_err(|e| Error::from_reason(format!("{e}")))?;
1628
+ serde_json::from_str(&buf)
1629
+ .map_err(|e| Error::from_reason(format!("invalid credentials json: {e}")))?
1630
+ } else {
1631
+ serde_json::from_str(&credentials_path)
1632
+ .map_err(|e| Error::from_reason(format!("invalid credentials json: {e}")))?
1633
+ };
1634
+
1635
+ let mut session_config = SessionConfig::default();
1636
+ session_config.device_id = device_id.clone();
1637
+ if let Ok(client_id_override) = std::env::var("LOX_LIBRESPOT_CLIENT_ID") {
1638
+ if !client_id_override.trim().is_empty() {
1639
+ session_config.client_id = client_id_override;
1640
+ }
1641
+ }
1642
+ // Spirc::new neemt zelf de connect stap; we maken hier alleen een verse session.
1643
+ let session = Session::new(session_config.clone(), None);
1644
+
1645
+ let connect_config = ConnectConfig {
1646
+ name: name.clone(),
1647
+ device_type: DeviceType::Speaker,
1648
+ is_group: false,
1649
+ // Start with full volume so we rely on zone-side volume control; we do not sync Spotify volume.
1650
+ // Spotify volume scale is 0..65535; use max to avoid muted start.
1651
+ initial_volume: u16::MAX,
1652
+ disable_volume: false,
1653
+ volume_steps: 64,
1654
+ };
1655
+
1656
+ let player_config = PlayerConfig::default();
1657
+ let mixer = SoftMixer::open(MixerConfig::default())
1658
+ .map_err(|e| Error::from_reason(format!("mixer init failed: {e}")))?;
1659
+ let volume_getter = mixer.get_soft_volume();
1660
+
1661
+ let (tx, mut rx) = mpsc::channel::<Bytes>(256);
1662
+
1663
+ runtime().spawn({
1664
+ let tsfn = tsfn.clone();
1665
+ async move {
1666
+ while let Some(chunk) = rx.recv().await {
1667
+ let _ = tsfn.call(chunk, ThreadsafeFunctionCallMode::NonBlocking);
1668
+ }
1669
+ }
1670
+ });
1671
+
1672
+ let log_tsfn_for_sink = log_tsfn.clone();
1673
+ let event_tsfn_for_sink = event_tsfn.clone();
1674
+ let first_chunk_flag = Arc::new(AtomicBool::new(false));
1675
+ let first_chunk_flag_for_sink = first_chunk_flag.clone();
1676
+ let last_pcm_at = Arc::new(AtomicU64::new(0));
1677
+ let last_pcm_at_for_sink = last_pcm_at.clone();
1678
+ let stream_start = Instant::now();
1679
+ let metric_sent = Arc::new(AtomicBool::new(false));
1680
+ let metric_sent_for_sink = metric_sent.clone();
1681
+ let sink_builder = {
1682
+ let tx_clone = tx.clone();
1683
+ let device_id = device_id.clone();
1684
+ let session_id = session_id.clone();
1685
+ move || {
1686
+ let sink = ChannelSink::new(
1687
+ tx_clone.clone(),
1688
+ AudioFormat::S16,
1689
+ 44100,
1690
+ 2,
1691
+ log_tsfn_for_sink.clone(),
1692
+ event_tsfn_for_sink.clone(),
1693
+ Some("connect_host".to_string()),
1694
+ Some(device_id.clone()),
1695
+ Some(session_id.clone()),
1696
+ None,
1697
+ None,
1698
+ stream_start,
1699
+ metric_sent_for_sink.clone(),
1700
+ first_chunk_flag_for_sink.clone(),
1701
+ last_pcm_at_for_sink.clone(),
1702
+ );
1703
+ Box::new(sink) as Box<dyn Sink>
1704
+ }
1705
+ };
1706
+
1707
+ let player = Player::new(player_config, session.clone(), volume_getter, sink_builder);
1708
+ // Forward player events to JS if requested.
1709
+ if let Some(tsfn_ev) = event_tsfn.clone() {
1710
+ let mut ev_rx = player.get_player_event_channel();
1711
+ let session_for_events = session.clone();
1712
+ let device_id_for_events = device_id.clone();
1713
+ let session_id_for_events = session_id.clone();
1714
+ let last_pcm_for_health = last_pcm_at.clone();
1715
+ let stop_flag_for_health = stop_flag_for_block.clone();
1716
+ let device_id_for_health = device_id.clone();
1717
+ let session_id_for_health = session_id.clone();
1718
+ let tsfn_health = tsfn_ev.clone();
1719
+ runtime().spawn(async move {
1720
+ let mut stall_reported = false;
1721
+ loop {
1722
+ tokio::time::sleep(Duration::from_secs(5)).await;
1723
+ if stop_flag_for_health.load(Ordering::Acquire) {
1724
+ break;
1725
+ }
1726
+ let last_ms = last_pcm_for_health.load(Ordering::Acquire);
1727
+ let now_ms = SystemTime::now()
1728
+ .duration_since(UNIX_EPOCH)
1729
+ .unwrap_or_else(|_| Duration::from_secs(0))
1730
+ .as_millis() as u64;
1731
+ let stall_ms = now_ms.saturating_sub(last_ms);
1732
+ let (code, message) = if last_ms == 0 {
1733
+ ("pcm_missing", "no pcm received yet")
1734
+ } else if stall_ms > 2000 {
1735
+ ("pcm_stalled", "pcm stalled")
1736
+ } else {
1737
+ ("pcm_ok", "pcm flowing")
1738
+ };
1739
+ if code == "pcm_ok" {
1740
+ stall_reported = false;
1741
+ }
1742
+ if code == "pcm_stalled" && !stall_reported {
1743
+ stall_reported = true;
1744
+ let stall_ms_u32 = stall_ms.min(u32::MAX as u64) as u32;
1745
+ let metric_payload = ConnectEvent {
1746
+ r#type: "metric".into(),
1747
+ device_id: Some(device_id_for_health.clone()),
1748
+ session_id: Some(session_id_for_health.clone()),
1749
+ track_id: None,
1750
+ uri: None,
1751
+ title: None,
1752
+ artist: None,
1753
+ album: None,
1754
+ duration_ms: None,
1755
+ position_ms: None,
1756
+ volume: None,
1757
+ error_code: None,
1758
+ error_message: None,
1759
+ metric_name: Some("buffer_stall_ms".into()),
1760
+ metric_value_ms: Some(stall_ms_u32),
1761
+ metric_message: Some("pcm stalled".into()),
1762
+ };
1763
+ let _ = tsfn_health
1764
+ .call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
1765
+ }
1766
+ let payload = ConnectEvent {
1767
+ r#type: "health".into(),
1768
+ device_id: Some(device_id_for_health.clone()),
1769
+ session_id: Some(session_id_for_health.clone()),
1770
+ track_id: None,
1771
+ uri: None,
1772
+ title: None,
1773
+ artist: None,
1774
+ album: None,
1775
+ duration_ms: None,
1776
+ position_ms: None,
1777
+ volume: None,
1778
+ error_code: Some(code.into()),
1779
+ error_message: Some(message.into()),
1780
+ metric_name: None,
1781
+ metric_value_ms: None,
1782
+ metric_message: None,
1783
+ };
1784
+ let _ = tsfn_health.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1785
+ }
1786
+ });
1787
+ runtime().spawn(async move {
1788
+ let mut last_position_ms: Option<u32> = None;
1789
+ let mut last_duration_ms: Option<u32> = None;
1790
+ let mut saw_playing = false;
1791
+ let mut first_event_logged = false;
1792
+ let mut event_count: u32 = 0;
1793
+ while let Some(ev) = ev_rx.recv().await {
1794
+ let event_name = match &ev {
1795
+ PlayerEvent::Playing { .. } => "playing",
1796
+ PlayerEvent::Paused { .. } => "paused",
1797
+ PlayerEvent::Loading { .. } => "loading",
1798
+ PlayerEvent::Stopped { .. } => "stopped",
1799
+ PlayerEvent::EndOfTrack { .. } => "end_of_track",
1800
+ PlayerEvent::Unavailable { .. } => "unavailable",
1801
+ PlayerEvent::Preloading { .. } => "preloading",
1802
+ PlayerEvent::TimeToPreloadNextTrack { .. } => "time_to_preload",
1803
+ PlayerEvent::VolumeChanged { .. } => "volume",
1804
+ PlayerEvent::PositionCorrection { .. } => "position_correction",
1805
+ PlayerEvent::PlayRequestIdChanged { .. } => "play_request_id",
1806
+ _ => "other",
1807
+ };
1808
+ if !first_event_logged {
1809
+ first_event_logged = true;
1810
+ emit_log_ctx(
1811
+ &log_tsfn,
1812
+ "debug",
1813
+ format!("first player event: {}", event_name),
1814
+ Some("connect_host"),
1815
+ Some(&device_id_for_events),
1816
+ Some(&session_id_for_events),
1817
+ );
1818
+ }
1819
+ event_count += 1;
1820
+ if event_count <= 5 {
1821
+ emit_log_ctx(
1822
+ &log_tsfn,
1823
+ "debug",
1824
+ format!("player event {}", event_name),
1825
+ Some("connect_host"),
1826
+ Some(&device_id_for_events),
1827
+ Some(&session_id_for_events),
1828
+ );
1829
+ }
1830
+ if event_name == "unavailable" {
1831
+ emit_log_ctx(
1832
+ &log_tsfn,
1833
+ "warn",
1834
+ "player reported track unavailable",
1835
+ Some("connect_host"),
1836
+ Some(&device_id_for_events),
1837
+ Some(&session_id_for_events),
1838
+ );
1839
+ }
1840
+
1841
+ let mut payload = match ev {
1842
+ PlayerEvent::Playing {
1843
+ track_id,
1844
+ position_ms,
1845
+ ..
1846
+ } => ConnectEvent {
1847
+ r#type: "playing".into(),
1848
+ device_id: Some(device_id_for_events.clone()),
1849
+ session_id: Some(session_id_for_events.clone()),
1850
+ track_id: Some(track_id.to_id()),
1851
+ uri: Some(track_id.to_uri()),
1852
+ title: None,
1853
+ artist: None,
1854
+ album: None,
1855
+ position_ms: Some(position_ms),
1856
+ duration_ms: None,
1857
+ volume: None,
1858
+ error_code: None,
1859
+ error_message: None,
1860
+ metric_name: None,
1861
+ metric_value_ms: None,
1862
+ metric_message: None,
1863
+ },
1864
+ PlayerEvent::Paused {
1865
+ track_id,
1866
+ position_ms,
1867
+ ..
1868
+ } => ConnectEvent {
1869
+ r#type: "paused".into(),
1870
+ device_id: Some(device_id_for_events.clone()),
1871
+ session_id: Some(session_id_for_events.clone()),
1872
+ track_id: Some(track_id.to_id()),
1873
+ uri: Some(track_id.to_uri()),
1874
+ title: None,
1875
+ artist: None,
1876
+ album: None,
1877
+ position_ms: Some(position_ms),
1878
+ duration_ms: None,
1879
+ volume: None,
1880
+ error_code: None,
1881
+ error_message: None,
1882
+ metric_name: None,
1883
+ metric_value_ms: None,
1884
+ metric_message: None,
1885
+ },
1886
+ PlayerEvent::Loading {
1887
+ track_id,
1888
+ position_ms,
1889
+ ..
1890
+ } => ConnectEvent {
1891
+ r#type: "loading".into(),
1892
+ device_id: Some(device_id_for_events.clone()),
1893
+ session_id: Some(session_id_for_events.clone()),
1894
+ track_id: Some(track_id.to_id()),
1895
+ uri: Some(track_id.to_uri()),
1896
+ title: None,
1897
+ artist: None,
1898
+ album: None,
1899
+ position_ms: Some(position_ms),
1900
+ duration_ms: None,
1901
+ volume: None,
1902
+ error_code: None,
1903
+ error_message: None,
1904
+ metric_name: None,
1905
+ metric_value_ms: None,
1906
+ metric_message: None,
1907
+ },
1908
+ PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1909
+ r#type: "stopped".into(),
1910
+ device_id: Some(device_id_for_events.clone()),
1911
+ session_id: Some(session_id_for_events.clone()),
1912
+ track_id: Some(track_id.to_id()),
1913
+ uri: Some(track_id.to_uri()),
1914
+ title: None,
1915
+ artist: None,
1916
+ album: None,
1917
+ position_ms: None,
1918
+ duration_ms: None,
1919
+ volume: None,
1920
+ error_code: None,
1921
+ error_message: None,
1922
+ metric_name: None,
1923
+ metric_value_ms: None,
1924
+ metric_message: None,
1925
+ },
1926
+ PlayerEvent::EndOfTrack { track_id, .. } => {
1927
+ if !saw_playing || last_duration_ms.is_none() {
1928
+ continue;
1929
+ }
1930
+ ConnectEvent {
1931
+ r#type: "end_of_track".into(),
1932
+ device_id: Some(device_id_for_events.clone()),
1933
+ session_id: Some(session_id_for_events.clone()),
1934
+ track_id: Some(track_id.to_id()),
1935
+ uri: Some(track_id.to_uri()),
1936
+ title: None,
1937
+ artist: None,
1938
+ album: None,
1939
+ position_ms: last_position_ms,
1940
+ duration_ms: last_duration_ms,
1941
+ volume: None,
1942
+ error_code: None,
1943
+ error_message: None,
1944
+ metric_name: None,
1945
+ metric_value_ms: None,
1946
+ metric_message: None,
1947
+ }
1948
+ }
1949
+ PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
1950
+ r#type: "unavailable".into(),
1951
+ device_id: Some(device_id_for_events.clone()),
1952
+ session_id: Some(session_id_for_events.clone()),
1953
+ track_id: Some(track_id.to_id()),
1954
+ uri: Some(track_id.to_uri()),
1955
+ title: None,
1956
+ artist: None,
1957
+ album: None,
1958
+ position_ms: None,
1959
+ duration_ms: None,
1960
+ volume: None,
1961
+ error_code: None,
1962
+ error_message: None,
1963
+ metric_name: None,
1964
+ metric_value_ms: None,
1965
+ metric_message: None,
1966
+ },
1967
+ PlayerEvent::VolumeChanged { volume } => ConnectEvent {
1968
+ r#type: "volume".into(),
1969
+ device_id: Some(device_id_for_events.clone()),
1970
+ session_id: Some(session_id_for_events.clone()),
1971
+ track_id: None,
1972
+ uri: None,
1973
+ title: None,
1974
+ artist: None,
1975
+ album: None,
1976
+ position_ms: None,
1977
+ duration_ms: None,
1978
+ volume: Some(volume),
1979
+ error_code: None,
1980
+ error_message: None,
1981
+ metric_name: None,
1982
+ metric_value_ms: None,
1983
+ metric_message: None,
1984
+ },
1985
+ PlayerEvent::PositionCorrection {
1986
+ track_id,
1987
+ position_ms,
1988
+ ..
1989
+ } => ConnectEvent {
1990
+ r#type: "position_correction".into(),
1991
+ device_id: Some(device_id_for_events.clone()),
1992
+ session_id: Some(session_id_for_events.clone()),
1993
+ track_id: Some(track_id.to_id()),
1994
+ uri: Some(track_id.to_uri()),
1995
+ title: None,
1996
+ artist: None,
1997
+ album: None,
1998
+ position_ms: Some(position_ms),
1999
+ duration_ms: None,
2000
+ volume: None,
2001
+ error_code: None,
2002
+ error_message: None,
2003
+ metric_name: None,
2004
+ metric_value_ms: None,
2005
+ metric_message: None,
2006
+ },
2007
+ _ => continue,
2008
+ };
2009
+
2010
+ // Best-effort metadata enrichment.
2011
+ if payload.title.is_none()
2012
+ || payload.duration_ms.is_none()
2013
+ || payload.uri.is_none()
2014
+ {
2015
+ let uri = payload.uri.clone().or_else(|| {
2016
+ payload
2017
+ .track_id
2018
+ .clone()
2019
+ .map(|tid| format!("spotify:track:{tid}"))
2020
+ });
2021
+ if let Some(uri) = uri {
2022
+ if let Ok(spotify_uri) = SpotifyUri::from_uri(&uri) {
2023
+ if let Ok(item) =
2024
+ AudioItem::get_file(&session_for_events, spotify_uri).await
2025
+ {
2026
+ payload.title = Some(item.name);
2027
+ payload.duration_ms = Some(item.duration_ms);
2028
+ payload.uri = Some(item.uri);
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ // Enrich artist/album names if missing.
2035
+ if payload.artist.is_none() || payload.album.is_none() {
2036
+ let uri = payload.uri.clone().or_else(|| {
2037
+ payload
2038
+ .track_id
2039
+ .clone()
2040
+ .map(|tid| format!("spotify:track:{tid}"))
2041
+ });
2042
+ if let Some(uri) = uri {
2043
+ if let Ok(spotify_uri) = SpotifyUri::from_uri(&uri) {
2044
+ if let Ok(track_meta) =
2045
+ Track::get(&session_for_events, &spotify_uri).await
2046
+ {
2047
+ if payload.album.is_none() {
2048
+ if let Ok(album_meta) =
2049
+ Album::get(&session_for_events, &track_meta.album.id)
2050
+ .await
2051
+ {
2052
+ payload.album = Some(album_meta.name);
2053
+ }
2054
+ }
2055
+ if payload.artist.is_none() {
2056
+ if let Some(first_artist) = track_meta.artists.first() {
2057
+ if let Ok(artist_meta) =
2058
+ Artist::get(&session_for_events, &first_artist.id)
2059
+ .await
2060
+ {
2061
+ payload.artist = Some(artist_meta.name);
2062
+ }
2063
+ }
2064
+ }
2065
+ }
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ match payload.r#type.as_str() {
2071
+ "playing" | "paused" | "position_correction" => {
2072
+ saw_playing = true;
2073
+ if let Some(pos) = payload.position_ms {
2074
+ last_position_ms = Some(pos);
2075
+ }
2076
+ if let Some(dur) = payload.duration_ms {
2077
+ last_duration_ms = Some(dur);
2078
+ }
2079
+ }
2080
+ _ => {}
2081
+ }
2082
+
2083
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2084
+ }
2085
+ });
2086
+ }
2087
+
2088
+ let (spirc, spirc_task) = Spirc::new(
2089
+ connect_config,
2090
+ session,
2091
+ credentials,
2092
+ player,
2093
+ Arc::new(mixer),
2094
+ )
2095
+ .await
2096
+ .map_err(|e| Error::from_reason(format!("spirc start failed: {e}")))?;
2097
+
2098
+ let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
2099
+ let task_handle = runtime().spawn(async move {
2100
+ tokio::select! {
2101
+ _ = spirc_task => {},
2102
+ _ = stop_rx.recv() => {},
2103
+ }
2104
+ });
2105
+
2106
+ Ok(ConnectHandle {
2107
+ spirc,
2108
+ stop_tx: Some(stop_tx),
2109
+ tsfn,
2110
+ sample_rate: 44100,
2111
+ channels: 2,
2112
+ stop_flag,
2113
+ task: task_handle,
2114
+ })
2115
+ })
2116
+ }
2117
+
2118
+ /// Legacy entrypoint removed from the JS API; kept for binary compatibility but always errors.
2119
+ #[napi]
2120
+ pub fn start_connect_device(
2121
+ _credentials_path: String,
2122
+ _name: String,
2123
+ _device_id: String,
2124
+ _on_chunk: JsFunction,
2125
+ _on_event: Option<JsFunction>,
2126
+ _on_log: Option<JsFunction>,
2127
+ ) -> Result<ConnectHandle> {
2128
+ Err(Error::from_reason(
2129
+ "startConnectDevice is deprecated; use startConnectDeviceWithToken(accessToken, clientId, ...)",
2130
+ ))
2131
+ }
2132
+
2133
+ /// Start a Spotify Connect device using a Web API access token + client id (bypasses builtin login).
2134
+ #[napi]
2135
+ pub fn start_connect_device_with_token(
2136
+ access_token: String,
2137
+ client_id: Option<String>,
2138
+ name: String,
2139
+ device_id: String,
2140
+ on_chunk: JsFunction,
2141
+ on_event: Option<JsFunction>,
2142
+ on_log: Option<JsFunction>,
2143
+ ) -> Result<ConnectHandle> {
2144
+ if access_token.trim().is_empty() {
2145
+ return Err(Error::from_reason("access token is required"));
2146
+ }
2147
+ if let Some(client_id) = client_id {
2148
+ if !client_id.trim().is_empty() {
2149
+ std::env::set_var("LOX_LIBRESPOT_CLIENT_ID", client_id);
2150
+ }
2151
+ }
2152
+
2153
+ // Exchange the access token for reusable librespot credentials (same as login_with_access_token).
2154
+ let credentials_json = {
2155
+ let token = access_token.clone();
2156
+ let device_for_session = device_id.clone();
2157
+ runtime()
2158
+ .block_on(async move {
2159
+ let mut session_config = SessionConfig::default();
2160
+ session_config.device_id = device_for_session.clone();
2161
+ if let Ok(client_id_override) = std::env::var("LOX_LIBRESPOT_CLIENT_ID") {
2162
+ if !client_id_override.trim().is_empty() {
2163
+ session_config.client_id = client_id_override;
2164
+ }
2165
+ }
2166
+
2167
+ let credentials = Credentials::with_access_token(token);
2168
+ let epoch_ms = SystemTime::now()
2169
+ .duration_since(UNIX_EPOCH)
2170
+ .map_err(|e| Error::from_reason(format!("{e}")))?
2171
+ .as_millis();
2172
+ let temp_dir = std::env::temp_dir().join(format!(
2173
+ "lox-librespot-oauth-connect-{}-{}",
2174
+ epoch_ms,
2175
+ std::process::id()
2176
+ ));
2177
+ fs::create_dir_all(&temp_dir)
2178
+ .map_err(|e| Error::from_reason(format!("{e}")))?;
2179
+ let cache = Cache::new(
2180
+ Some(&temp_dir),
2181
+ None::<&std::path::PathBuf>,
2182
+ None::<&std::path::PathBuf>,
2183
+ None,
2184
+ )
2185
+ .map_err(|e| Error::from_reason(format!("{e}")))?;
2186
+
2187
+ let session = Session::new(session_config, Some(cache.clone()));
2188
+ session
2189
+ .connect(credentials.clone(), true)
2190
+ .await
2191
+ .map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
2192
+
2193
+ let reusable_credentials = cache
2194
+ .credentials()
2195
+ .ok_or_else(|| Error::from_reason("no reusable credentials after oauth login"))?;
2196
+
2197
+ drop(session);
2198
+ let _ = fs::remove_dir_all(&temp_dir);
2199
+
2200
+ serde_json::to_string(&reusable_credentials)
2201
+ .map_err(|e| Error::from_reason(format!("{e}")))
2202
+ })
2203
+ .map_err(|e| Error::from_reason(format!("{e}")))?
2204
+ };
2205
+
2206
+ start_connect_device_inner(
2207
+ credentials_json,
2208
+ name,
2209
+ device_id,
2210
+ on_chunk,
2211
+ on_event,
2212
+ on_log,
2213
+ )
2214
+ }
2215
+
2216
+ /// Perform access-token login and return the credentials JSON blob.
2217
+ #[napi]
2218
+ pub async fn login_with_access_token(
2219
+ access_token: String,
2220
+ device_name: Option<String>,
2221
+ ) -> Result<CredentialsResult> {
2222
+ if access_token.trim().is_empty() {
2223
+ return Err(Error::from_reason("access token is required"));
2224
+ }
2225
+
2226
+ let mut session_config = SessionConfig::default();
2227
+ if let Some(name) = device_name {
2228
+ session_config.device_id = name;
2229
+ }
2230
+ let credentials = Credentials::with_access_token(access_token);
2231
+
2232
+ let epoch_ms = SystemTime::now()
2233
+ .duration_since(UNIX_EPOCH)
2234
+ .map_err(|e| Error::from_reason(format!("{e}")))?
2235
+ .as_millis();
2236
+ let temp_dir = std::env::temp_dir().join(format!(
2237
+ "lox-librespot-oauth-{}-{}",
2238
+ epoch_ms,
2239
+ std::process::id()
2240
+ ));
2241
+ fs::create_dir_all(&temp_dir).map_err(|e| Error::from_reason(format!("{e}")))?;
2242
+ let cache = Cache::new(
2243
+ Some(&temp_dir),
2244
+ None::<&std::path::PathBuf>,
2245
+ None::<&std::path::PathBuf>,
2246
+ None,
2247
+ )
2248
+ .map_err(|e| Error::from_reason(format!("{e}")))?;
2249
+
2250
+ let session = Session::new(session_config, Some(cache.clone()));
2251
+ session
2252
+ .connect(credentials.clone(), true)
2253
+ .await
2254
+ .map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
2255
+
2256
+ let reusable_credentials = cache
2257
+ .credentials()
2258
+ .ok_or_else(|| Error::from_reason("no reusable credentials after oauth login"))?;
2259
+
2260
+ // Explicitly drop session to stop background tasks after acquiring credentials.
2261
+ drop(session);
2262
+ let _ = fs::remove_dir_all(&temp_dir);
2263
+
2264
+ let credentials_json = serde_json::to_string_pretty(&reusable_credentials)
2265
+ .map_err(|e| Error::from_reason(format!("{e}")))?;
2266
+
2267
+ Ok(CredentialsResult {
2268
+ username: reusable_credentials.username.clone().unwrap_or_default(),
2269
+ credentials_json,
2270
+ })
2271
+ }
2272
+
2273
+ /// Start a Zeroconf login and return the credentials blob once a user connects in the Spotify app.
2274
+ #[napi]
2275
+ pub async fn start_zeroconf_login(
2276
+ device_id: String,
2277
+ name: Option<String>,
2278
+ timeout_ms: Option<u32>,
2279
+ ) -> Result<CredentialsResult> {
2280
+ let device_id = device_id.trim();
2281
+ if device_id.is_empty() {
2282
+ return Err(Error::from_reason("device_id is required"));
2283
+ }
2284
+ let mut discovery = Discovery::builder(device_id.to_string(), device_id.to_string())
2285
+ .name(name.unwrap_or_else(|| "LoxAudio".into()))
2286
+ .device_type(DeviceType::Speaker)
2287
+ .launch()
2288
+ .map_err(|e| Error::from_reason(format!("zeroconf launch failed: {e}")))?;
2289
+
2290
+ let wait_for = timeout_ms.unwrap_or(120_000); // default 120s
2291
+ let creds = timeout(Duration::from_millis(wait_for as u64), async {
2292
+ discovery.next().await
2293
+ })
2294
+ .await
2295
+ .map_err(|_| Error::from_reason("zeroconf login timed out"))?
2296
+ .ok_or_else(|| Error::from_reason("zeroconf ended without credentials"))?;
2297
+
2298
+ let credentials_json =
2299
+ serde_json::to_string_pretty(&creds).map_err(|e| Error::from_reason(format!("{e}")))?;
2300
+
2301
+ Ok(CredentialsResult {
2302
+ username: creds.username.clone().unwrap_or_default(),
2303
+ credentials_json,
2304
+ })
2305
+ }