@lox-audioserver/node-librespot 0.3.4 → 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
|
}
|
|
@@ -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:
|
|
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
|
-
.
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
-
|
|
1431
|
+
_ => vec![
|
|
1432
|
+
AudioFileFormat::OGG_VORBIS_320,
|
|
1433
|
+
AudioFileFormat::MP3_320,
|
|
1434
|
+
AudioFileFormat::MP3_256,
|
|
1435
|
+
],
|
|
1387
1436
|
};
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -2147,6 +2217,108 @@ fn start_connect_device_inner(
|
|
|
2147
2217
|
.await
|
|
2148
2218
|
.map_err(|e| Error::from_reason(format!("spirc start failed: {e}")))?;
|
|
2149
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
|
+
|
|
2150
2322
|
let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
|
|
2151
2323
|
let task_handle = runtime().spawn(async move {
|
|
2152
2324
|
tokio::select! {
|
|
@@ -2252,8 +2424,7 @@ pub fn start_connect_device_with_token(
|
|
|
2252
2424
|
epoch_ms,
|
|
2253
2425
|
std::process::id()
|
|
2254
2426
|
));
|
|
2255
|
-
fs::create_dir_all(&temp_dir)
|
|
2256
|
-
.map_err(|e| Error::from_reason(format!("{e}")))?;
|
|
2427
|
+
fs::create_dir_all(&temp_dir).map_err(|e| Error::from_reason(format!("{e}")))?;
|
|
2257
2428
|
let cache = Cache::new(
|
|
2258
2429
|
Some(&temp_dir),
|
|
2259
2430
|
None::<&std::path::PathBuf>,
|
|
@@ -2268,9 +2439,9 @@ pub fn start_connect_device_with_token(
|
|
|
2268
2439
|
.await
|
|
2269
2440
|
.map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
|
|
2270
2441
|
|
|
2271
|
-
let reusable_credentials = cache
|
|
2272
|
-
|
|
2273
|
-
|
|
2442
|
+
let reusable_credentials = cache.credentials().ok_or_else(|| {
|
|
2443
|
+
Error::from_reason("no reusable credentials after oauth login")
|
|
2444
|
+
})?;
|
|
2274
2445
|
|
|
2275
2446
|
drop(session);
|
|
2276
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. */
|