@lox-audioserver/node-librespot 0.3.4 → 0.3.6

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.4",
3
+ "version": "0.3.6",
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
  }
@@ -654,6 +693,9 @@ impl LibrespotSession {
654
693
  env.create_string(&metric_message)?,
655
694
  )?;
656
695
  }
696
+ if let Some(creds_json) = val.credentials_json {
697
+ obj.set_named_property("credentialsJson", env.create_string(&creds_json)?)?;
698
+ }
657
699
  Ok(vec![obj.into_unknown()])
658
700
  })
659
701
  })
@@ -812,6 +854,7 @@ impl LibrespotSession {
812
854
  metric_name: Some("decode_error".into()),
813
855
  metric_value_ms: None,
814
856
  metric_message: Some(event.message.clone()),
857
+ credentials_json: None,
815
858
  };
816
859
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
817
860
  }
@@ -844,6 +887,7 @@ impl LibrespotSession {
844
887
  metric_name: None,
845
888
  metric_value_ms: None,
846
889
  metric_message: None,
890
+ credentials_json: None,
847
891
  };
848
892
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
849
893
  }
@@ -902,6 +946,7 @@ impl LibrespotSession {
902
946
  metric_name: None,
903
947
  metric_value_ms: None,
904
948
  metric_message: None,
949
+ credentials_json: None,
905
950
  };
906
951
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
907
952
  }
@@ -955,6 +1000,7 @@ impl LibrespotSession {
955
1000
  metric_name: Some("buffer_stall_ms".into()),
956
1001
  metric_value_ms: Some(stall_ms_u32),
957
1002
  metric_message: Some("pcm stalled".into()),
1003
+ credentials_json: None,
958
1004
  };
959
1005
  let _ = tsfn_ev.call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
960
1006
  }
@@ -975,6 +1021,7 @@ impl LibrespotSession {
975
1021
  metric_name: None,
976
1022
  metric_value_ms: None,
977
1023
  metric_message: None,
1024
+ credentials_json: None,
978
1025
  };
979
1026
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
980
1027
  }
@@ -1068,6 +1115,7 @@ impl LibrespotSession {
1068
1115
  metric_name: None,
1069
1116
  metric_value_ms: None,
1070
1117
  metric_message: None,
1118
+ credentials_json: None,
1071
1119
  };
1072
1120
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1073
1121
  }
@@ -1092,6 +1140,7 @@ impl LibrespotSession {
1092
1140
  metric_name: None,
1093
1141
  metric_value_ms: None,
1094
1142
  metric_message: None,
1143
+ credentials_json: None,
1095
1144
  },
1096
1145
  PlayerEvent::Paused { track_id, position_ms, .. } => ConnectEvent {
1097
1146
  r#type: "paused".into(),
@@ -1110,6 +1159,7 @@ impl LibrespotSession {
1110
1159
  metric_name: None,
1111
1160
  metric_value_ms: None,
1112
1161
  metric_message: None,
1162
+ credentials_json: None,
1113
1163
  },
1114
1164
  PlayerEvent::Loading { track_id, position_ms, .. } => ConnectEvent {
1115
1165
  r#type: "loading".into(),
@@ -1128,6 +1178,7 @@ impl LibrespotSession {
1128
1178
  metric_name: None,
1129
1179
  metric_value_ms: None,
1130
1180
  metric_message: None,
1181
+ credentials_json: None,
1131
1182
  },
1132
1183
  PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1133
1184
  r#type: "stopped".into(),
@@ -1146,16 +1197,17 @@ impl LibrespotSession {
1146
1197
  metric_name: None,
1147
1198
  metric_value_ms: None,
1148
1199
  metric_message: None,
1200
+ credentials_json: None,
1149
1201
  },
1150
1202
  PlayerEvent::EndOfTrack { track_id, .. } => {
1151
1203
  if !saw_playing || last_duration_ms.is_none() {
1152
1204
  if !error_sent_for_spawn.swap(true, Ordering::AcqRel) {
1153
- let fallback_id = track_id.to_id();
1205
+ let fallback_id = Some(track_id.to_id());
1154
1206
  let payload = ConnectEvent {
1155
1207
  r#type: "error".into(),
1156
1208
  device_id: Some(device_id_for_events.clone()),
1157
1209
  session_id: Some(session_id_for_events.clone()),
1158
- track_id: Some(fallback_id),
1210
+ track_id: fallback_id,
1159
1211
  uri: Some(track_id.to_uri()),
1160
1212
  title: None,
1161
1213
  artist: None,
@@ -1168,6 +1220,7 @@ impl LibrespotSession {
1168
1220
  metric_name: None,
1169
1221
  metric_value_ms: None,
1170
1222
  metric_message: None,
1223
+ credentials_json: None,
1171
1224
  };
1172
1225
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1173
1226
  }
@@ -1190,6 +1243,7 @@ impl LibrespotSession {
1190
1243
  metric_name: None,
1191
1244
  metric_value_ms: None,
1192
1245
  metric_message: None,
1246
+ credentials_json: None,
1193
1247
  }
1194
1248
  }
1195
1249
  PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
@@ -1209,6 +1263,7 @@ impl LibrespotSession {
1209
1263
  metric_name: None,
1210
1264
  metric_value_ms: None,
1211
1265
  metric_message: None,
1266
+ credentials_json: None,
1212
1267
  },
1213
1268
  PlayerEvent::VolumeChanged { volume } => ConnectEvent {
1214
1269
  r#type: "volume".into(),
@@ -1227,6 +1282,7 @@ impl LibrespotSession {
1227
1282
  metric_name: None,
1228
1283
  metric_value_ms: None,
1229
1284
  metric_message: None,
1285
+ credentials_json: None,
1230
1286
  },
1231
1287
  PlayerEvent::PositionCorrection { track_id, position_ms, .. } => ConnectEvent {
1232
1288
  r#type: "position_correction".into(),
@@ -1245,6 +1301,7 @@ impl LibrespotSession {
1245
1301
  metric_name: None,
1246
1302
  metric_value_ms: None,
1247
1303
  metric_message: None,
1304
+ credentials_json: None,
1248
1305
  },
1249
1306
  _ => continue,
1250
1307
  };
@@ -1357,64 +1414,61 @@ impl LibrespotSession {
1357
1414
  .try_into()
1358
1415
  .map_err(|e| Error::from_reason(format!("invalid spotify id: {e:?}")))?;
1359
1416
 
1360
- let (encrypted_file, key) = runtime()
1361
- .block_on(async {
1362
- let audio_item = AudioItem::get_file(&session, spotify_uri.clone())
1363
- .await
1364
- .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:?}")))?;
1365
1421
 
1366
- let select_format =
1367
- |files: &AudioFiles, bitrate: Option<u32>| -> Option<(AudioFileFormat, FileId)> {
1368
- let prefer = match bitrate {
1369
- Some(96) => {
1370
- vec![AudioFileFormat::OGG_VORBIS_96, AudioFileFormat::MP3_96]
1371
- }
1372
- Some(160) => {
1373
- vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
1374
- }
1375
- _ => vec![
1376
- AudioFileFormat::OGG_VORBIS_320,
1377
- AudioFileFormat::MP3_320,
1378
- AudioFileFormat::MP3_256,
1379
- ],
1380
- };
1381
- for f in prefer {
1382
- if let Some(id) = files.get(&f) {
1383
- return Some((f, *id));
1384
- }
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]
1427
+ }
1428
+ Some(160) => {
1429
+ vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
1385
1430
  }
1386
- files.iter().next().map(|(f, id)| (*f, *id))
1431
+ _ => vec![
1432
+ AudioFileFormat::OGG_VORBIS_320,
1433
+ AudioFileFormat::MP3_320,
1434
+ AudioFileFormat::MP3_256,
1435
+ ],
1387
1436
  };
1388
-
1389
- let (format, file_id) = select_format(&audio_item.files, bitrate_pref)
1390
- .ok_or_else(|| Error::from_reason("no audio files available"))?;
1391
-
1392
- let bytes_per_second = stream_data_rate(format)
1393
- .ok_or_else(|| Error::from_reason("unable to compute data rate"))?;
1394
-
1395
- let encrypted_file = AudioFile::open(&session, file_id, bytes_per_second)
1396
- .await
1397
- .map_err(|e| {
1398
- Error::from_reason(format!("failed to open audio file: {e:?}"))
1399
- })?;
1400
-
1401
- let key = match session.audio_key().request(track_id, file_id).await {
1402
- Ok(key) => Some(key),
1403
- Err(e) => {
1404
- emit_log_ctx(
1405
- &log_tsfn,
1406
- "warn",
1407
- format!("audio key unavailable, continuing without decryption: {e:?}"),
1408
- Some("download_track"),
1409
- None,
1410
- None,
1411
- );
1412
- None
1437
+ for f in prefer {
1438
+ if let Some(id) = files.get(&f) {
1439
+ return Some((f, *id));
1440
+ }
1413
1441
  }
1442
+ files.iter().next().map(|(f, id)| (*f, *id))
1414
1443
  };
1415
1444
 
1416
- Ok::<_, Error>((encrypted_file, key))
1417
- })?;
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
+ })?;
1418
1472
 
1419
1473
  let stop_flag_clone = stop_flag.clone();
1420
1474
  let log_tsfn_clone = log_tsfn.clone();
@@ -1630,6 +1684,9 @@ fn start_connect_device_inner(
1630
1684
  if let Some(metric_message) = val.metric_message {
1631
1685
  obj.set_named_property("metricMessage", env.create_string(&metric_message)?)?;
1632
1686
  }
1687
+ if let Some(creds_json) = val.credentials_json {
1688
+ obj.set_named_property("credentialsJson", env.create_string(&creds_json)?)?;
1689
+ }
1633
1690
  Ok(vec![obj.into_unknown()])
1634
1691
  })
1635
1692
  })
@@ -1690,6 +1747,7 @@ fn start_connect_device_inner(
1690
1747
  session_config.client_id = client_id_override;
1691
1748
  }
1692
1749
  }
1750
+ let client_id_for_discovery = session_config.client_id.clone();
1693
1751
  // Spirc::new neemt zelf de connect stap; we maken hier alleen een verse session.
1694
1752
  let session = Session::new(session_config.clone(), None);
1695
1753
 
@@ -1697,12 +1755,12 @@ fn start_connect_device_inner(
1697
1755
  name: name.clone(),
1698
1756
  device_type: DeviceType::Speaker,
1699
1757
  is_group: false,
1700
- emit_set_queue_events: false,
1701
1758
  // Start with full volume so we rely on zone-side volume control; we do not sync Spotify volume.
1702
1759
  // Spotify volume scale is 0..65535; use max to avoid muted start.
1703
1760
  initial_volume: u16::MAX,
1704
1761
  disable_volume: false,
1705
1762
  volume_steps: 64,
1763
+ emit_set_queue_events: false,
1706
1764
  };
1707
1765
 
1708
1766
  let player_config = PlayerConfig::default();
@@ -1757,6 +1815,8 @@ fn start_connect_device_inner(
1757
1815
  };
1758
1816
 
1759
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();
1760
1820
  // Forward player events to JS if requested.
1761
1821
  if let Some(tsfn_ev) = event_tsfn.clone() {
1762
1822
  let mut ev_rx = player.get_player_event_channel();
@@ -1811,6 +1871,7 @@ fn start_connect_device_inner(
1811
1871
  metric_name: Some("buffer_stall_ms".into()),
1812
1872
  metric_value_ms: Some(stall_ms_u32),
1813
1873
  metric_message: Some("pcm stalled".into()),
1874
+ credentials_json: None,
1814
1875
  };
1815
1876
  let _ = tsfn_health
1816
1877
  .call(metric_payload, ThreadsafeFunctionCallMode::NonBlocking);
@@ -1832,6 +1893,7 @@ fn start_connect_device_inner(
1832
1893
  metric_name: None,
1833
1894
  metric_value_ms: None,
1834
1895
  metric_message: None,
1896
+ credentials_json: None,
1835
1897
  };
1836
1898
  let _ = tsfn_health.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
1837
1899
  }
@@ -1912,6 +1974,7 @@ fn start_connect_device_inner(
1912
1974
  metric_name: None,
1913
1975
  metric_value_ms: None,
1914
1976
  metric_message: None,
1977
+ credentials_json: None,
1915
1978
  },
1916
1979
  PlayerEvent::Paused {
1917
1980
  track_id,
@@ -1934,6 +1997,7 @@ fn start_connect_device_inner(
1934
1997
  metric_name: None,
1935
1998
  metric_value_ms: None,
1936
1999
  metric_message: None,
2000
+ credentials_json: None,
1937
2001
  },
1938
2002
  PlayerEvent::Loading {
1939
2003
  track_id,
@@ -1956,6 +2020,7 @@ fn start_connect_device_inner(
1956
2020
  metric_name: None,
1957
2021
  metric_value_ms: None,
1958
2022
  metric_message: None,
2023
+ credentials_json: None,
1959
2024
  },
1960
2025
  PlayerEvent::Stopped { track_id, .. } => ConnectEvent {
1961
2026
  r#type: "stopped".into(),
@@ -1974,6 +2039,7 @@ fn start_connect_device_inner(
1974
2039
  metric_name: None,
1975
2040
  metric_value_ms: None,
1976
2041
  metric_message: None,
2042
+ credentials_json: None,
1977
2043
  },
1978
2044
  PlayerEvent::EndOfTrack { track_id, .. } => {
1979
2045
  if !saw_playing || last_duration_ms.is_none() {
@@ -1996,6 +2062,7 @@ fn start_connect_device_inner(
1996
2062
  metric_name: None,
1997
2063
  metric_value_ms: None,
1998
2064
  metric_message: None,
2065
+ credentials_json: None,
1999
2066
  }
2000
2067
  }
2001
2068
  PlayerEvent::Unavailable { track_id, .. } => ConnectEvent {
@@ -2015,6 +2082,7 @@ fn start_connect_device_inner(
2015
2082
  metric_name: None,
2016
2083
  metric_value_ms: None,
2017
2084
  metric_message: None,
2085
+ credentials_json: None,
2018
2086
  },
2019
2087
  PlayerEvent::VolumeChanged { volume } => ConnectEvent {
2020
2088
  r#type: "volume".into(),
@@ -2033,6 +2101,7 @@ fn start_connect_device_inner(
2033
2101
  metric_name: None,
2034
2102
  metric_value_ms: None,
2035
2103
  metric_message: None,
2104
+ credentials_json: None,
2036
2105
  },
2037
2106
  PlayerEvent::PositionCorrection {
2038
2107
  track_id,
@@ -2055,6 +2124,7 @@ fn start_connect_device_inner(
2055
2124
  metric_name: None,
2056
2125
  metric_value_ms: None,
2057
2126
  metric_message: None,
2127
+ credentials_json: None,
2058
2128
  },
2059
2129
  _ => continue,
2060
2130
  };
@@ -2137,6 +2207,78 @@ fn start_connect_device_inner(
2137
2207
  });
2138
2208
  }
2139
2209
 
2210
+ // Log observer: detect audio_key_error and decoder errors from librespot log output.
2211
+ // This mirrors the same pattern used in stream_track to ensure the JS layer receives
2212
+ // error events and can trigger recovery (e.g. re-authentication, skip to next track).
2213
+ if let Some(tsfn_ev) = event_tsfn.clone() {
2214
+ let mut log_rx = subscribe_log_events();
2215
+ let error_sent = Arc::new(AtomicBool::new(false));
2216
+ let decoder_metric_sent = Arc::new(AtomicBool::new(false));
2217
+ let stop_flag_for_logs = stop_flag_for_block.clone();
2218
+ let device_id_for_logs = device_id.clone();
2219
+ let session_id_for_logs = session_id.clone();
2220
+ runtime().spawn(async move {
2221
+ while let Some(event) = log_rx.recv().await {
2222
+ if stop_flag_for_logs.load(Ordering::Acquire) {
2223
+ break;
2224
+ }
2225
+ if is_decoder_error(&event) {
2226
+ if !decoder_metric_sent.swap(true, Ordering::AcqRel) {
2227
+ let payload = ConnectEvent {
2228
+ r#type: "metric".into(),
2229
+ device_id: Some(device_id_for_logs.clone()),
2230
+ session_id: Some(session_id_for_logs.clone()),
2231
+ track_id: None,
2232
+ uri: None,
2233
+ title: None,
2234
+ artist: None,
2235
+ album: None,
2236
+ duration_ms: None,
2237
+ position_ms: None,
2238
+ volume: None,
2239
+ error_code: None,
2240
+ error_message: None,
2241
+ metric_name: Some("decode_error".into()),
2242
+ metric_value_ms: None,
2243
+ metric_message: Some(event.message.clone()),
2244
+ credentials_json: None,
2245
+ };
2246
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2247
+ }
2248
+ }
2249
+ if error_sent.load(Ordering::Acquire) {
2250
+ continue;
2251
+ }
2252
+ if !is_audio_key_error(&event) {
2253
+ continue;
2254
+ }
2255
+ if error_sent.swap(true, Ordering::AcqRel) {
2256
+ continue;
2257
+ }
2258
+ let payload = ConnectEvent {
2259
+ r#type: "error".into(),
2260
+ device_id: Some(device_id_for_logs.clone()),
2261
+ session_id: Some(session_id_for_logs.clone()),
2262
+ track_id: None,
2263
+ uri: None,
2264
+ title: None,
2265
+ artist: None,
2266
+ album: None,
2267
+ duration_ms: None,
2268
+ position_ms: None,
2269
+ volume: None,
2270
+ error_code: Some("audio_key_error".into()),
2271
+ error_message: Some(event.message.clone()),
2272
+ metric_name: None,
2273
+ metric_value_ms: None,
2274
+ metric_message: None,
2275
+ credentials_json: None,
2276
+ };
2277
+ let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2278
+ }
2279
+ });
2280
+ }
2281
+
2140
2282
  let (spirc, spirc_task) = Spirc::new(
2141
2283
  connect_config,
2142
2284
  session,
@@ -2147,6 +2289,108 @@ fn start_connect_device_inner(
2147
2289
  .await
2148
2290
  .map_err(|e| Error::from_reason(format!("spirc start failed: {e}")))?;
2149
2291
 
2292
+ // Launch mDNS Discovery so the device is visible to ALL Spotify users on the LAN,
2293
+ // not just the owner of the credentials used to start the connect host. This is the
2294
+ // same mechanism hardware devices (Sonos, Onkyo, etc.) use. When a different user
2295
+ // connects via mDNS, their credentials are emitted as a "credentials_changed" event
2296
+ // so the JS layer can restart the connect host under the new account.
2297
+ match Discovery::builder(device_id.clone(), client_id_for_discovery)
2298
+ .name(name.clone())
2299
+ .device_type(DeviceType::Speaker)
2300
+ .launch()
2301
+ {
2302
+ Ok(mut discovery) => {
2303
+ emit_log_ctx(
2304
+ &log_tsfn_for_disc,
2305
+ "info",
2306
+ "mDNS discovery started; device is now visible to all Spotify users on the LAN",
2307
+ Some("connect_host"),
2308
+ Some(&device_id),
2309
+ Some(&session_id),
2310
+ );
2311
+ let event_tsfn_disc = event_tsfn.clone();
2312
+ let log_tsfn_disc = log_tsfn_for_disc.clone();
2313
+ let stop_flag_disc = stop_flag_for_block.clone();
2314
+ let device_id_disc = device_id.clone();
2315
+ let session_id_disc = session_id.clone();
2316
+ runtime().spawn(async move {
2317
+ loop {
2318
+ if stop_flag_disc.load(Ordering::Acquire) {
2319
+ break;
2320
+ }
2321
+ match tokio::time::timeout(
2322
+ Duration::from_secs(2),
2323
+ discovery.next(),
2324
+ )
2325
+ .await
2326
+ {
2327
+ Ok(None) => break,
2328
+ Ok(Some(creds)) => {
2329
+ match serde_json::to_string_pretty(&creds) {
2330
+ Ok(json) => {
2331
+ emit_log_ctx(
2332
+ &log_tsfn_disc,
2333
+ "info",
2334
+ "mDNS: new Spotify user connected; emitting credentials_changed",
2335
+ Some("connect_host"),
2336
+ Some(&device_id_disc),
2337
+ Some(&session_id_disc),
2338
+ );
2339
+ if let Some(ref tsfn) = event_tsfn_disc {
2340
+ let payload = ConnectEvent {
2341
+ r#type: "credentials_changed".into(),
2342
+ credentials_json: Some(json),
2343
+ device_id: Some(device_id_disc.clone()),
2344
+ session_id: Some(session_id_disc.clone()),
2345
+ track_id: None,
2346
+ uri: None,
2347
+ title: None,
2348
+ artist: None,
2349
+ album: None,
2350
+ duration_ms: None,
2351
+ position_ms: None,
2352
+ volume: None,
2353
+ error_code: None,
2354
+ error_message: None,
2355
+ metric_name: None,
2356
+ metric_value_ms: None,
2357
+ metric_message: None,
2358
+ };
2359
+ let _ = tsfn.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2360
+ }
2361
+ break;
2362
+ }
2363
+ Err(e) => {
2364
+ emit_log_ctx(
2365
+ &log_tsfn_disc,
2366
+ "warn",
2367
+ &format!("mDNS: failed to serialize new credentials: {e}"),
2368
+ Some("connect_host"),
2369
+ Some(&device_id_disc),
2370
+ Some(&session_id_disc),
2371
+ );
2372
+ }
2373
+ }
2374
+ }
2375
+ Err(_timeout) => {
2376
+ // No new connection in this window; loop and check stop flag.
2377
+ }
2378
+ }
2379
+ }
2380
+ });
2381
+ }
2382
+ Err(e) => {
2383
+ emit_log_ctx(
2384
+ &log_tsfn_for_disc,
2385
+ "warn",
2386
+ &format!("mDNS discovery failed to start (device only visible to owner account): {e}"),
2387
+ Some("connect_host"),
2388
+ Some(&device_id),
2389
+ Some(&session_id),
2390
+ );
2391
+ }
2392
+ }
2393
+
2150
2394
  let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
2151
2395
  let task_handle = runtime().spawn(async move {
2152
2396
  tokio::select! {
@@ -2252,8 +2496,7 @@ pub fn start_connect_device_with_token(
2252
2496
  epoch_ms,
2253
2497
  std::process::id()
2254
2498
  ));
2255
- fs::create_dir_all(&temp_dir)
2256
- .map_err(|e| Error::from_reason(format!("{e}")))?;
2499
+ fs::create_dir_all(&temp_dir).map_err(|e| Error::from_reason(format!("{e}")))?;
2257
2500
  let cache = Cache::new(
2258
2501
  Some(&temp_dir),
2259
2502
  None::<&std::path::PathBuf>,
@@ -2268,9 +2511,9 @@ pub fn start_connect_device_with_token(
2268
2511
  .await
2269
2512
  .map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
2270
2513
 
2271
- let reusable_credentials = cache
2272
- .credentials()
2273
- .ok_or_else(|| Error::from_reason("no reusable credentials after oauth login"))?;
2514
+ let reusable_credentials = cache.credentials().ok_or_else(|| {
2515
+ Error::from_reason("no reusable credentials after oauth login")
2516
+ })?;
2274
2517
 
2275
2518
  drop(session);
2276
2519
  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. */