@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
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,
|
|
16
|
-
|
|
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:
|
|
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
|
-
.
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2274
|
-
|
|
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. */
|