@lox-audioserver/node-librespot 0.3.3 → 0.3.5

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/dist/types.d.ts CHANGED
@@ -25,7 +25,7 @@ export interface CredentialsResult {
25
25
  }
26
26
  /** Helper signature for OAuth-based credentials minting. */
27
27
  export type LoginWithAccessToken = (accessToken: string, deviceName?: string) => Promise<CredentialsResult>;
28
- export type LibrespotEventType = 'playing' | 'paused' | 'loading' | 'stopped' | 'end_of_track' | 'unavailable' | 'volume' | 'position_correction' | 'health' | 'error' | 'metric' | 'preloading' | 'time_to_preload' | 'play_request_id' | 'other';
28
+ export type LibrespotEventType = 'playing' | 'paused' | 'loading' | 'stopped' | 'end_of_track' | 'unavailable' | 'volume' | 'position_correction' | 'health' | 'error' | 'metric' | 'preloading' | 'time_to_preload' | 'play_request_id' | 'credentials_changed' | 'other';
29
29
  export type LibrespotErrorCode = 'audio_key_error' | 'no_pcm' | 'end_of_track' | 'pcm_missing' | 'pcm_stalled' | 'pcm_ok' | 'unavailable' | 'unknown';
30
30
  /** Event payload emitted by the connect host. */
31
31
  export interface ConnectEvent {
@@ -45,6 +45,7 @@ export interface ConnectEvent {
45
45
  metricName?: string;
46
46
  metricValueMs?: number;
47
47
  metricMessage?: string;
48
+ credentialsJson?: string;
48
49
  }
49
50
  /** Log payload emitted by the native module. */
50
51
  export interface LogEvent {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-librespot",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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
@@ -12,8 +12,8 @@ use bytes::Bytes;
12
12
  use librespot_audio::{AudioDecrypt, AudioFile};
13
13
  use librespot_connect::{ConnectConfig, Spirc};
14
14
  use librespot_core::{
15
- authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, SpotifyId,
16
- SpotifyUri, spotify_id::FileId,
15
+ authentication::Credentials, cache::Cache, config::SessionConfig, session::Session,
16
+ spotify_id::FileId, SpotifyId, SpotifyUri,
17
17
  };
18
18
  use librespot_discovery::{DeviceType, Discovery};
19
19
  use librespot_metadata::audio::{AudioFileFormat, AudioFiles, AudioItem};
@@ -43,12 +43,45 @@ use tokio_stream::StreamExt;
43
43
  static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
44
44
  static LOGGER_INIT: OnceLock<()> = OnceLock::new();
45
45
  static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
46
+ static COLLECTION_PARSE_LOG_NEXT_AT_MS: AtomicU64 = AtomicU64::new(0);
46
47
 
47
48
  fn next_session_id(prefix: &str) -> String {
48
49
  let next = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
49
50
  format!("{prefix}-{next}")
50
51
  }
51
52
 
53
+ fn unix_epoch_ms() -> u64 {
54
+ SystemTime::now()
55
+ .duration_since(UNIX_EPOCH)
56
+ .unwrap_or_else(|_| Duration::from_secs(0))
57
+ .as_millis() as u64
58
+ }
59
+
60
+ fn normalize_collection_parse_warning(event: &mut LogEvent) -> bool {
61
+ let scope = event.scope.as_deref().unwrap_or("").to_lowercase();
62
+ if !scope.contains("librespot_core::dealer") {
63
+ return false;
64
+ }
65
+ let message = event.message.to_lowercase();
66
+ let looks_like_collection_parse_noise = message
67
+ .contains("failure during data parsing for hm://collection/collection/")
68
+ && message.contains("base64 decoding failed");
69
+ if !looks_like_collection_parse_noise {
70
+ return false;
71
+ }
72
+
73
+ let now = unix_epoch_ms();
74
+ let next_at = COLLECTION_PARSE_LOG_NEXT_AT_MS.load(Ordering::Relaxed);
75
+ if now < next_at {
76
+ return true;
77
+ }
78
+ COLLECTION_PARSE_LOG_NEXT_AT_MS.store(now + 60_000, Ordering::Relaxed);
79
+ event.level = "debug".to_string();
80
+ event.message =
81
+ "suppressed recurring dealer parse warning for hm://collection payload".to_string();
82
+ false
83
+ }
84
+
52
85
  fn runtime() -> &'static tokio::runtime::Runtime {
53
86
  RUNTIME.get_or_init(|| {
54
87
  tokio::runtime::Runtime::new().expect("failed to create tokio runtime for librespot addon")
@@ -131,6 +164,8 @@ pub struct ConnectEvent {
131
164
  pub metric_name: Option<String>,
132
165
  pub metric_value_ms: Option<u32>,
133
166
  pub metric_message: Option<String>,
167
+ /// Credentials JSON blob delivered via mDNS discovery when a new Spotify user connects.
168
+ pub credentials_json: Option<String>,
134
169
  }
135
170
 
136
171
  /// Log payload emitted by the native module.
@@ -308,13 +343,16 @@ impl Log for NativeLogger {
308
343
  }
309
344
 
310
345
  fn log(&self, record: &Record<'_>) {
311
- let event = LogEvent {
346
+ let mut event = LogEvent {
312
347
  level: record.level().to_string().to_lowercase(),
313
348
  message: record.args().to_string(),
314
349
  scope: Some(record.target().to_string()),
315
350
  device_id: None,
316
351
  session_id: None,
317
352
  };
353
+ if normalize_collection_parse_warning(&mut event) {
354
+ return;
355
+ }
318
356
  let mut observers = log_observers()
319
357
  .lock()
320
358
  .unwrap_or_else(|err| err.into_inner());
@@ -537,6 +575,7 @@ impl Sink for ChannelSink {
537
575
  metric_name: Some("first_pcm_ms".into()),
538
576
  metric_value_ms: Some(elapsed_ms_u32),
539
577
  metric_message: None,
578
+ credentials_json: None,
540
579
  };
541
580
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
542
581
  }
@@ -550,6 +589,7 @@ impl Sink for ChannelSink {
550
589
  self.last_pcm_at.store(now_ms, Ordering::Release);
551
590
  }
552
591
  // Pacing: throttle to approximate realtime based on sample count.
592
+ // Send first chunk immediately to minimize startup latency; apply pacing after forwarding.
553
593
  let bytes_per_sample = match self.format {
554
594
  AudioFormat::S24 => 3,
555
595
  AudioFormat::S32 | AudioFormat::F32 => 4,
@@ -557,19 +597,17 @@ impl Sink for ChannelSink {
557
597
  };
558
598
  let samples = bytes.len() / (bytes_per_sample * self.channels as usize);
559
599
  let duration = Duration::from_secs_f64(samples as f64 / self.sample_rate as f64);
600
+ if self.tx.try_send(bytes).is_err() {
601
+ // Drop chunk if JS side is backpressured to avoid blocking the player thread.
602
+ }
560
603
  let start = self.start.get_or_insert_with(Instant::now);
561
604
  self.expected_elapsed += duration;
562
605
  let target = *start + self.expected_elapsed;
563
606
  let now = Instant::now();
564
607
  let sleep_dur = target.saturating_duration_since(now);
565
-
566
608
  if !sleep_dur.is_zero() {
567
609
  sleep(sleep_dur);
568
610
  }
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
611
  Ok(())
574
612
  }
575
613
  }
@@ -655,6 +693,9 @@ impl LibrespotSession {
655
693
  env.create_string(&metric_message)?,
656
694
  )?;
657
695
  }
696
+ if let Some(creds_json) = val.credentials_json {
697
+ obj.set_named_property("credentialsJson", env.create_string(&creds_json)?)?;
698
+ }
658
699
  Ok(vec![obj.into_unknown()])
659
700
  })
660
701
  })
@@ -813,6 +854,7 @@ impl LibrespotSession {
813
854
  metric_name: Some("decode_error".into()),
814
855
  metric_value_ms: None,
815
856
  metric_message: Some(event.message.clone()),
857
+ credentials_json: None,
816
858
  };
817
859
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
818
860
  }
@@ -845,6 +887,7 @@ impl LibrespotSession {
845
887
  metric_name: None,
846
888
  metric_value_ms: None,
847
889
  metric_message: None,
890
+ credentials_json: None,
848
891
  };
849
892
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
850
893
  }
@@ -903,6 +946,7 @@ impl LibrespotSession {
903
946
  metric_name: None,
904
947
  metric_value_ms: None,
905
948
  metric_message: None,
949
+ credentials_json: None,
906
950
  };
907
951
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
908
952
  }
@@ -956,6 +1000,7 @@ impl LibrespotSession {
956
1000
  metric_name: Some("buffer_stall_ms".into()),
957
1001
  metric_value_ms: Some(stall_ms_u32),
958
1002
  metric_message: Some("pcm stalled".into()),
1003
+ credentials_json: None,
959
1004
  };
960
1005
  let _ = tsfn_ev.call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
961
1006
  }
@@ -976,6 +1021,7 @@ impl LibrespotSession {
976
1021
  metric_name: None,
977
1022
  metric_value_ms: None,
978
1023
  metric_message: None,
1024
+ credentials_json: None,
979
1025
  };
980
1026
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
981
1027
  }
@@ -1069,6 +1115,7 @@ impl LibrespotSession {
1069
1115
  metric_name: None,
1070
1116
  metric_value_ms: None,
1071
1117
  metric_message: None,
1118
+ credentials_json: None,
1072
1119
  };
1073
1120
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1074
1121
  }
@@ -1093,6 +1140,7 @@ impl LibrespotSession {
1093
1140
  metric_name: None,
1094
1141
  metric_value_ms: None,
1095
1142
  metric_message: None,
1143
+ credentials_json: None,
1096
1144
  },
1097
1145
  PlayerEvent::Paused { track_id, position_ms, .. } => ConnectEvent {
1098
1146
  r#type: "paused".into(),
@@ -1111,6 +1159,7 @@ impl LibrespotSession {
1111
1159
  metric_name: None,
1112
1160
  metric_value_ms: None,
1113
1161
  metric_message: None,
1162
+ credentials_json: None,
1114
1163
  },
1115
1164
  PlayerEvent::Loading { track_id, position_ms, .. } => ConnectEvent {
1116
1165
  r#type: "loading".into(),
@@ -1129,6 +1178,7 @@ impl LibrespotSession {
1129
1178
  metric_name: None,
1130
1179
  metric_value_ms: None,
1131
1180
  metric_message: None,
1181
+ credentials_json: None,
1132
1182
  },
1133
1183
  PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1134
1184
  r#type: "stopped".into(),
@@ -1147,16 +1197,17 @@ impl LibrespotSession {
1147
1197
  metric_name: None,
1148
1198
  metric_value_ms: None,
1149
1199
  metric_message: None,
1200
+ credentials_json: None,
1150
1201
  },
1151
1202
  PlayerEvent::EndOfTrack { track_id, .. } => {
1152
1203
  if !saw_playing || last_duration_ms.is_none() {
1153
1204
  if !error_sent_for_spawn.swap(true, Ordering::AcqRel) {
1154
- let fallback_id = track_id.to_id();
1205
+ let fallback_id = Some(track_id.to_id());
1155
1206
  let payload = ConnectEvent {
1156
1207
  r#type: "error".into(),
1157
1208
  device_id: Some(device_id_for_events.clone()),
1158
1209
  session_id: Some(session_id_for_events.clone()),
1159
- track_id: Some(fallback_id),
1210
+ track_id: fallback_id,
1160
1211
  uri: Some(track_id.to_uri()),
1161
1212
  title: None,
1162
1213
  artist: None,
@@ -1169,6 +1220,7 @@ impl LibrespotSession {
1169
1220
  metric_name: None,
1170
1221
  metric_value_ms: None,
1171
1222
  metric_message: None,
1223
+ credentials_json: None,
1172
1224
  };
1173
1225
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1174
1226
  }
@@ -1191,6 +1243,7 @@ impl LibrespotSession {
1191
1243
  metric_name: None,
1192
1244
  metric_value_ms: None,
1193
1245
  metric_message: None,
1246
+ credentials_json: None,
1194
1247
  }
1195
1248
  }
1196
1249
  PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
@@ -1210,6 +1263,7 @@ impl LibrespotSession {
1210
1263
  metric_name: None,
1211
1264
  metric_value_ms: None,
1212
1265
  metric_message: None,
1266
+ credentials_json: None,
1213
1267
  },
1214
1268
  PlayerEvent::VolumeChanged { volume } => ConnectEvent {
1215
1269
  r#type: "volume".into(),
@@ -1228,6 +1282,7 @@ impl LibrespotSession {
1228
1282
  metric_name: None,
1229
1283
  metric_value_ms: None,
1230
1284
  metric_message: None,
1285
+ credentials_json: None,
1231
1286
  },
1232
1287
  PlayerEvent::PositionCorrection { track_id, position_ms, .. } => ConnectEvent {
1233
1288
  r#type: "position_correction".into(),
@@ -1246,6 +1301,7 @@ impl LibrespotSession {
1246
1301
  metric_name: None,
1247
1302
  metric_value_ms: None,
1248
1303
  metric_message: None,
1304
+ credentials_json: None,
1249
1305
  },
1250
1306
  _ => continue,
1251
1307
  };
@@ -1358,64 +1414,61 @@ impl LibrespotSession {
1358
1414
  .try_into()
1359
1415
  .map_err(|e| Error::from_reason(format!("invalid spotify id: {e:?}")))?;
1360
1416
 
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:?}")))?;
1417
+ let (encrypted_file, key) = runtime().block_on(async {
1418
+ let audio_item = AudioItem::get_file(&session, spotify_uri.clone())
1419
+ .await
1420
+ .map_err(|e| Error::from_reason(format!("failed to load audio item: {e:?}")))?;
1366
1421
 
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
- }
1422
+ let select_format =
1423
+ |files: &AudioFiles, bitrate: Option<u32>| -> Option<(AudioFileFormat, FileId)> {
1424
+ let prefer = match bitrate {
1425
+ Some(96) => {
1426
+ vec![AudioFileFormat::OGG_VORBIS_96, AudioFileFormat::MP3_96]
1386
1427
  }
1387
- files.iter().next().map(|(f, id)| (*f, *id))
1428
+ Some(160) => {
1429
+ vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
1430
+ }
1431
+ _ => vec![
1432
+ AudioFileFormat::OGG_VORBIS_320,
1433
+ AudioFileFormat::MP3_320,
1434
+ AudioFileFormat::MP3_256,
1435
+ ],
1388
1436
  };
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
1437
+ for f in prefer {
1438
+ if let Some(id) = files.get(&f) {
1439
+ return Some((f, *id));
1440
+ }
1414
1441
  }
1442
+ files.iter().next().map(|(f, id)| (*f, *id))
1415
1443
  };
1416
1444
 
1417
- Ok::<_, Error>((encrypted_file, key))
1418
- })?;
1445
+ let (format, file_id) = select_format(&audio_item.files, bitrate_pref)
1446
+ .ok_or_else(|| Error::from_reason("no audio files available"))?;
1447
+
1448
+ let bytes_per_second = stream_data_rate(format)
1449
+ .ok_or_else(|| Error::from_reason("unable to compute data rate"))?;
1450
+
1451
+ let encrypted_file = AudioFile::open(&session, file_id, bytes_per_second)
1452
+ .await
1453
+ .map_err(|e| Error::from_reason(format!("failed to open audio file: {e:?}")))?;
1454
+
1455
+ let key = match session.audio_key().request(track_id, file_id).await {
1456
+ Ok(key) => Some(key),
1457
+ Err(e) => {
1458
+ emit_log_ctx(
1459
+ &log_tsfn,
1460
+ "warn",
1461
+ format!("audio key unavailable, continuing without decryption: {e:?}"),
1462
+ Some("download_track"),
1463
+ None,
1464
+ None,
1465
+ );
1466
+ None
1467
+ }
1468
+ };
1469
+
1470
+ Ok::<_, Error>((encrypted_file, key))
1471
+ })?;
1419
1472
 
1420
1473
  let stop_flag_clone = stop_flag.clone();
1421
1474
  let log_tsfn_clone = log_tsfn.clone();
@@ -1631,6 +1684,9 @@ fn start_connect_device_inner(
1631
1684
  if let Some(metric_message) = val.metric_message {
1632
1685
  obj.set_named_property("metricMessage", env.create_string(&metric_message)?)?;
1633
1686
  }
1687
+ if let Some(creds_json) = val.credentials_json {
1688
+ obj.set_named_property("credentialsJson", env.create_string(&creds_json)?)?;
1689
+ }
1634
1690
  Ok(vec![obj.into_unknown()])
1635
1691
  })
1636
1692
  })
@@ -1691,6 +1747,7 @@ fn start_connect_device_inner(
1691
1747
  session_config.client_id = client_id_override;
1692
1748
  }
1693
1749
  }
1750
+ let client_id_for_discovery = session_config.client_id.clone();
1694
1751
  // Spirc::new neemt zelf de connect stap; we maken hier alleen een verse session.
1695
1752
  let session = Session::new(session_config.clone(), None);
1696
1753
 
@@ -1698,12 +1755,12 @@ fn start_connect_device_inner(
1698
1755
  name: name.clone(),
1699
1756
  device_type: DeviceType::Speaker,
1700
1757
  is_group: false,
1701
- emit_set_queue_events: false,
1702
1758
  // Start with full volume so we rely on zone-side volume control; we do not sync Spotify volume.
1703
1759
  // Spotify volume scale is 0..65535; use max to avoid muted start.
1704
1760
  initial_volume: u16::MAX,
1705
1761
  disable_volume: false,
1706
1762
  volume_steps: 64,
1763
+ emit_set_queue_events: false,
1707
1764
  };
1708
1765
 
1709
1766
  let player_config = PlayerConfig::default();
@@ -1758,6 +1815,8 @@ fn start_connect_device_inner(
1758
1815
  };
1759
1816
 
1760
1817
  let player = Player::new(player_config, session.clone(), volume_getter, sink_builder);
1818
+ // Clone log_tsfn before it is moved into the player-event spawn below.
1819
+ let log_tsfn_for_disc = log_tsfn.clone();
1761
1820
  // Forward player events to JS if requested.
1762
1821
  if let Some(tsfn_ev) = event_tsfn.clone() {
1763
1822
  let mut ev_rx = player.get_player_event_channel();
@@ -1812,6 +1871,7 @@ fn start_connect_device_inner(
1812
1871
  metric_name: Some("buffer_stall_ms".into()),
1813
1872
  metric_value_ms: Some(stall_ms_u32),
1814
1873
  metric_message: Some("pcm stalled".into()),
1874
+ credentials_json: None,
1815
1875
  };
1816
1876
  let _ = tsfn_health
1817
1877
  .call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
@@ -1833,6 +1893,7 @@ fn start_connect_device_inner(
1833
1893
  metric_name: None,
1834
1894
  metric_value_ms: None,
1835
1895
  metric_message: None,
1896
+ credentials_json: None,
1836
1897
  };
1837
1898
  let _ = tsfn_health.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1838
1899
  }
@@ -1913,6 +1974,7 @@ fn start_connect_device_inner(
1913
1974
  metric_name: None,
1914
1975
  metric_value_ms: None,
1915
1976
  metric_message: None,
1977
+ credentials_json: None,
1916
1978
  },
1917
1979
  PlayerEvent::Paused {
1918
1980
  track_id,
@@ -1935,6 +1997,7 @@ fn start_connect_device_inner(
1935
1997
  metric_name: None,
1936
1998
  metric_value_ms: None,
1937
1999
  metric_message: None,
2000
+ credentials_json: None,
1938
2001
  },
1939
2002
  PlayerEvent::Loading {
1940
2003
  track_id,
@@ -1957,6 +2020,7 @@ fn start_connect_device_inner(
1957
2020
  metric_name: None,
1958
2021
  metric_value_ms: None,
1959
2022
  metric_message: None,
2023
+ credentials_json: None,
1960
2024
  },
1961
2025
  PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1962
2026
  r#type: "stopped".into(),
@@ -1975,6 +2039,7 @@ fn start_connect_device_inner(
1975
2039
  metric_name: None,
1976
2040
  metric_value_ms: None,
1977
2041
  metric_message: None,
2042
+ credentials_json: None,
1978
2043
  },
1979
2044
  PlayerEvent::EndOfTrack { track_id, .. } => {
1980
2045
  if !saw_playing || last_duration_ms.is_none() {
@@ -1997,6 +2062,7 @@ fn start_connect_device_inner(
1997
2062
  metric_name: None,
1998
2063
  metric_value_ms: None,
1999
2064
  metric_message: None,
2065
+ credentials_json: None,
2000
2066
  }
2001
2067
  }
2002
2068
  PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
@@ -2016,6 +2082,7 @@ fn start_connect_device_inner(
2016
2082
  metric_name: None,
2017
2083
  metric_value_ms: None,
2018
2084
  metric_message: None,
2085
+ credentials_json: None,
2019
2086
  },
2020
2087
  PlayerEvent::VolumeChanged { volume } => ConnectEvent {
2021
2088
  r#type: "volume".into(),
@@ -2034,6 +2101,7 @@ fn start_connect_device_inner(
2034
2101
  metric_name: None,
2035
2102
  metric_value_ms: None,
2036
2103
  metric_message: None,
2104
+ credentials_json: None,
2037
2105
  },
2038
2106
  PlayerEvent::PositionCorrection {
2039
2107
  track_id,
@@ -2056,6 +2124,7 @@ fn start_connect_device_inner(
2056
2124
  metric_name: None,
2057
2125
  metric_value_ms: None,
2058
2126
  metric_message: None,
2127
+ credentials_json: None,
2059
2128
  },
2060
2129
  _ => continue,
2061
2130
  };
@@ -2148,6 +2217,108 @@ fn start_connect_device_inner(
2148
2217
  .await
2149
2218
  .map_err(|e| Error::from_reason(format!("spirc start failed: {e}")))?;
2150
2219
 
2220
+ // Launch mDNS Discovery so the device is visible to ALL Spotify users on the LAN,
2221
+ // not just the owner of the credentials used to start the connect host. This is the
2222
+ // same mechanism hardware devices (Sonos, Onkyo, etc.) use. When a different user
2223
+ // connects via mDNS, their credentials are emitted as a "credentials_changed" event
2224
+ // so the JS layer can restart the connect host under the new account.
2225
+ match Discovery::builder(device_id.clone(), client_id_for_discovery)
2226
+ .name(name.clone())
2227
+ .device_type(DeviceType::Speaker)
2228
+ .launch()
2229
+ {
2230
+ Ok(mut discovery) => {
2231
+ emit_log_ctx(
2232
+ &log_tsfn_for_disc,
2233
+ "info",
2234
+ "mDNS discovery started; device is now visible to all Spotify users on the LAN",
2235
+ Some("connect_host"),
2236
+ Some(&device_id),
2237
+ Some(&session_id),
2238
+ );
2239
+ let event_tsfn_disc = event_tsfn.clone();
2240
+ let log_tsfn_disc = log_tsfn_for_disc.clone();
2241
+ let stop_flag_disc = stop_flag_for_block.clone();
2242
+ let device_id_disc = device_id.clone();
2243
+ let session_id_disc = session_id.clone();
2244
+ runtime().spawn(async move {
2245
+ loop {
2246
+ if stop_flag_disc.load(Ordering::Acquire) {
2247
+ break;
2248
+ }
2249
+ match tokio::time::timeout(
2250
+ Duration::from_secs(2),
2251
+ discovery.next(),
2252
+ )
2253
+ .await
2254
+ {
2255
+ Ok(None) => break,
2256
+ Ok(Some(creds)) => {
2257
+ match serde_json::to_string_pretty(&creds) {
2258
+ Ok(json) => {
2259
+ emit_log_ctx(
2260
+ &log_tsfn_disc,
2261
+ "info",
2262
+ "mDNS: new Spotify user connected; emitting credentials_changed",
2263
+ Some("connect_host"),
2264
+ Some(&device_id_disc),
2265
+ Some(&session_id_disc),
2266
+ );
2267
+ if let Some(ref tsfn) = event_tsfn_disc {
2268
+ let payload = ConnectEvent {
2269
+ r#type: "credentials_changed".into(),
2270
+ credentials_json: Some(json),
2271
+ device_id: Some(device_id_disc.clone()),
2272
+ session_id: Some(session_id_disc.clone()),
2273
+ track_id: None,
2274
+ uri: None,
2275
+ title: None,
2276
+ artist: None,
2277
+ album: None,
2278
+ duration_ms: None,
2279
+ position_ms: None,
2280
+ volume: None,
2281
+ error_code: None,
2282
+ error_message: None,
2283
+ metric_name: None,
2284
+ metric_value_ms: None,
2285
+ metric_message: None,
2286
+ };
2287
+ let _ = tsfn.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2288
+ }
2289
+ break;
2290
+ }
2291
+ Err(e) => {
2292
+ emit_log_ctx(
2293
+ &log_tsfn_disc,
2294
+ "warn",
2295
+ &format!("mDNS: failed to serialize new credentials: {e}"),
2296
+ Some("connect_host"),
2297
+ Some(&device_id_disc),
2298
+ Some(&session_id_disc),
2299
+ );
2300
+ }
2301
+ }
2302
+ }
2303
+ Err(_timeout) => {
2304
+ // No new connection in this window; loop and check stop flag.
2305
+ }
2306
+ }
2307
+ }
2308
+ });
2309
+ }
2310
+ Err(e) => {
2311
+ emit_log_ctx(
2312
+ &log_tsfn_for_disc,
2313
+ "warn",
2314
+ &format!("mDNS discovery failed to start (device only visible to owner account): {e}"),
2315
+ Some("connect_host"),
2316
+ Some(&device_id),
2317
+ Some(&session_id),
2318
+ );
2319
+ }
2320
+ }
2321
+
2151
2322
  let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
2152
2323
  let task_handle = runtime().spawn(async move {
2153
2324
  tokio::select! {
@@ -2253,8 +2424,7 @@ pub fn start_connect_device_with_token(
2253
2424
  epoch_ms,
2254
2425
  std::process::id()
2255
2426
  ));
2256
- fs::create_dir_all(&temp_dir)
2257
- .map_err(|e| Error::from_reason(format!("{e}")))?;
2427
+ fs::create_dir_all(&temp_dir).map_err(|e| Error::from_reason(format!("{e}")))?;
2258
2428
  let cache = Cache::new(
2259
2429
  Some(&temp_dir),
2260
2430
  None::<&std::path::PathBuf>,
@@ -2269,9 +2439,9 @@ pub fn start_connect_device_with_token(
2269
2439
  .await
2270
2440
  .map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
2271
2441
 
2272
- let reusable_credentials = cache
2273
- .credentials()
2274
- .ok_or_else(|| Error::from_reason("no reusable credentials after oauth login"))?;
2442
+ let reusable_credentials = cache.credentials().ok_or_else(|| {
2443
+ Error::from_reason("no reusable credentials after oauth login")
2444
+ })?;
2275
2445
 
2276
2446
  drop(session);
2277
2447
  let _ = fs::remove_dir_all(&temp_dir);
package/src/types.ts CHANGED
@@ -50,6 +50,7 @@ export type LibrespotEventType =
50
50
  | 'preloading'
51
51
  | 'time_to_preload'
52
52
  | 'play_request_id'
53
+ | 'credentials_changed'
53
54
  | 'other';
54
55
 
55
56
  export type LibrespotErrorCode =
@@ -80,6 +81,7 @@ export interface ConnectEvent {
80
81
  metricName?: string;
81
82
  metricValueMs?: number;
82
83
  metricMessage?: string;
84
+ credentialsJson?: string;
83
85
  }
84
86
 
85
87
  /** Log payload emitted by the native module. */