@lox-audioserver/node-librespot 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-librespot",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Node.js bindings for librespot (Spotify Connect) via N-API with prebuild support.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/lib.rs CHANGED
@@ -513,6 +513,11 @@ impl Sink for ChannelSink {
513
513
  }
514
514
 
515
515
  fn stop(&mut self) -> SinkResult<()> {
516
+ // Reset pacing so the next start() (e.g. after Connect pause) is paced from now,
517
+ // not from the original stream_start. Without this the post-resume catch-up logic
518
+ // forwards minutes of decoded PCM at full speed.
519
+ self.start = None;
520
+ self.expected_elapsed = Duration::from_millis(0);
516
521
  Ok(())
517
522
  }
518
523
 
@@ -606,10 +611,20 @@ impl Sink for ChannelSink {
606
611
  if self.tx.try_send(bytes).is_err() {
607
612
  // Drop chunk if JS side is backpressured to avoid blocking the player thread.
608
613
  }
609
- let start = self.start.get_or_insert_with(Instant::now);
610
- self.expected_elapsed += duration;
611
- let target = *start + self.expected_elapsed;
612
614
  let now = Instant::now();
615
+ // If wall-clock has drifted far ahead of our expected timeline (a pause that didn't
616
+ // route through stop()/start(), a decoder stall, etc.), rebase pacing onto `now`.
617
+ // Otherwise `sleep_dur` saturates to 0 for the duration of the gap and the sink
618
+ // dumps the catch-up backlog at full speed downstream.
619
+ if let Some(start) = self.start {
620
+ if now > start + self.expected_elapsed + Duration::from_millis(200) {
621
+ self.start = None;
622
+ self.expected_elapsed = Duration::from_millis(0);
623
+ }
624
+ }
625
+ let start = *self.start.get_or_insert(now);
626
+ self.expected_elapsed += duration;
627
+ let target = start + self.expected_elapsed;
613
628
  let sleep_dur = target.saturating_duration_since(now);
614
629
  if !sleep_dur.is_zero() {
615
630
  sleep(sleep_dur);
@@ -1850,12 +1865,21 @@ fn start_connect_device_inner(
1850
1865
  let player = Player::new(player_config, session.clone(), volume_getter, sink_builder);
1851
1866
  // Clone log_tsfn before it is moved into the player-event spawn below.
1852
1867
  let log_tsfn_for_disc = log_tsfn.clone();
1868
+ // [issue #252] Bumped by the player-event observer on signals that
1869
+ // prove audio is flowing (`playing` / `position_correction`). Read by
1870
+ // the log observer further down to suppress transient `audio_key_error`
1871
+ // events that fire while spirc is already recovering from an
1872
+ // account-side dealer-disconnect burst. A permanent audio-key fault
1873
+ // never produces a healthy event, so the error still surfaces after
1874
+ // the grace window.
1875
+ let healthy_seq = Arc::new(AtomicU64::new(0));
1853
1876
  // Forward player events to JS if requested.
1854
1877
  if let Some(tsfn_ev) = event_tsfn.clone() {
1855
1878
  let mut ev_rx = player.get_player_event_channel();
1856
1879
  let session_for_events = session.clone();
1857
1880
  let device_id_for_events = device_id.clone();
1858
1881
  let session_id_for_events = session_id.clone();
1882
+ let healthy_seq_for_events = healthy_seq.clone();
1859
1883
  let last_pcm_for_health = last_pcm_at.clone();
1860
1884
  let stop_flag_for_health = stop_flag_for_block.clone();
1861
1885
  let device_id_for_health = device_id.clone();
@@ -2235,6 +2259,13 @@ fn start_connect_device_inner(
2235
2259
  _ => {}
2236
2260
  }
2237
2261
 
2262
+ // [issue #252] Signal "audio is actually flowing" to the
2263
+ // log observer. Excludes `paused` because a paused stream
2264
+ // can't prove the dealer/session is healthy.
2265
+ if matches!(payload.r#type.as_str(), "playing" | "position_correction") {
2266
+ healthy_seq_for_events.fetch_add(1, Ordering::AcqRel);
2267
+ }
2268
+
2238
2269
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2239
2270
  }
2240
2271
  });
@@ -2250,6 +2281,16 @@ fn start_connect_device_inner(
2250
2281
  let stop_flag_for_logs = stop_flag_for_block.clone();
2251
2282
  let device_id_for_logs = device_id.clone();
2252
2283
  let session_id_for_logs = session_id.clone();
2284
+ let healthy_seq_for_logs = healthy_seq.clone();
2285
+ // [issue #252] Grace window before surfacing audio_key_error to JS.
2286
+ // Account-side Spotify dealer-disconnect bursts cause every
2287
+ // offload=false zone to emit a single audio-key-timeout log line,
2288
+ // while spirc reconnects internally within seconds. Without this
2289
+ // delay every burst trips the per-zone cooldown in
2290
+ // SpotifyConnectInstance and produces ~5 minutes of unplayable
2291
+ // state per zone. 5s is well above typical spirc recovery time
2292
+ // and well below the cooldown window.
2293
+ const AUDIO_KEY_GRACE_MS: u64 = 5000;
2253
2294
  runtime().spawn(async move {
2254
2295
  while let Some(event) = log_rx.recv().await {
2255
2296
  if stop_flag_for_logs.load(Ordering::Acquire) {
@@ -2307,7 +2348,28 @@ fn start_connect_device_inner(
2307
2348
  metric_message: None,
2308
2349
  credentials_json: None,
2309
2350
  };
2310
- let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2351
+ // [issue #252] Defer the emit by AUDIO_KEY_GRACE_MS and
2352
+ // suppress it if a healthy event arrived in the meantime
2353
+ // (spirc finished its internal reconnect). Re-arm
2354
+ // `error_sent` on suppression so a *later* fault in the
2355
+ // same session can still surface.
2356
+ let baseline = healthy_seq_for_logs.load(Ordering::Acquire);
2357
+ let healthy_seq_for_check = healthy_seq_for_logs.clone();
2358
+ let stop_flag_for_grace = stop_flag_for_logs.clone();
2359
+ let error_sent_for_reset = error_sent.clone();
2360
+ let tsfn_ev_for_grace = tsfn_ev.clone();
2361
+ runtime().spawn(async move {
2362
+ tokio::time::sleep(Duration::from_millis(AUDIO_KEY_GRACE_MS)).await;
2363
+ if stop_flag_for_grace.load(Ordering::Acquire) {
2364
+ return;
2365
+ }
2366
+ if healthy_seq_for_check.load(Ordering::Acquire) != baseline {
2367
+ error_sent_for_reset.store(false, Ordering::Release);
2368
+ return;
2369
+ }
2370
+ let _ = tsfn_ev_for_grace
2371
+ .call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2372
+ });
2311
2373
  }
2312
2374
  });
2313
2375
  }