@lox-audioserver/node-librespot 0.4.3 → 0.4.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/index.js +3 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/librespot_addon.node +0 -0
- package/prebuilds/linux-arm64-gnu/librespot_addon.node +0 -0
- package/prebuilds/linux-x64-gnu/librespot_addon.node +0 -0
- package/prebuilds/win32-x64-msvc/librespot_addon.node +0 -0
- package/src/index.ts +3 -0
- package/src/lib.rs +148 -73
- package/src/types.ts +6 -0
package/dist/index.js
CHANGED
|
@@ -95,6 +95,9 @@ function wrapSession(session) {
|
|
|
95
95
|
resolveAudioFile: (opts) => {
|
|
96
96
|
return session.resolveAudioFile({ uri: opts.uri, bitrate: opts.bitrate });
|
|
97
97
|
},
|
|
98
|
+
resolveAudioFileAsync: (opts) => {
|
|
99
|
+
return session.resolveAudioFileAsync({ uri: opts.uri, bitrate: opts.bitrate });
|
|
100
|
+
},
|
|
98
101
|
close: () => session.close(),
|
|
99
102
|
};
|
|
100
103
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -87,6 +87,12 @@ export interface LibrespotSession {
|
|
|
87
87
|
streamTrack(opts: StreamTrackOpts, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): StreamHandle;
|
|
88
88
|
/** Resolve CDN URL + AES key for a track without downloading audio. */
|
|
89
89
|
resolveAudioFile(opts: DownloadTrackOpts): ResolveAudioFileResult;
|
|
90
|
+
/**
|
|
91
|
+
* Async variant of {@link resolveAudioFile}: the blocking CDN/key lookup runs
|
|
92
|
+
* on the libuv threadpool, so it never stalls the Node event loop. Prefer this
|
|
93
|
+
* on the playback hot path.
|
|
94
|
+
*/
|
|
95
|
+
resolveAudioFileAsync(opts: DownloadTrackOpts): Promise<ResolveAudioFileResult>;
|
|
90
96
|
close(): Promise<void>;
|
|
91
97
|
}
|
|
92
98
|
export interface StreamHandle {
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/index.ts
CHANGED
|
@@ -149,6 +149,9 @@ function wrapSession(session: LibrespotSession) {
|
|
|
149
149
|
resolveAudioFile: (opts: DownloadTrackOpts): ResolveAudioFileResult => {
|
|
150
150
|
return (session as any).resolveAudioFile({ uri: opts.uri, bitrate: opts.bitrate });
|
|
151
151
|
},
|
|
152
|
+
resolveAudioFileAsync: (opts: DownloadTrackOpts): Promise<ResolveAudioFileResult> => {
|
|
153
|
+
return (session as any).resolveAudioFileAsync({ uri: opts.uri, bitrate: opts.bitrate });
|
|
154
|
+
},
|
|
152
155
|
close: () => session.close(),
|
|
153
156
|
};
|
|
154
157
|
}
|
package/src/lib.rs
CHANGED
|
@@ -27,11 +27,11 @@ use librespot_playback::{
|
|
|
27
27
|
player::{Player, PlayerEvent},
|
|
28
28
|
};
|
|
29
29
|
use log::{LevelFilter, Log, Metadata as LogMetadata, Record};
|
|
30
|
-
use napi::bindgen_prelude::{Error, Result};
|
|
30
|
+
use napi::bindgen_prelude::{AsyncTask, Error, Result};
|
|
31
31
|
use napi::threadsafe_function::{
|
|
32
32
|
ErrorStrategy, ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode,
|
|
33
33
|
};
|
|
34
|
-
use napi::JsFunction;
|
|
34
|
+
use napi::{Env, JsFunction, Task};
|
|
35
35
|
use napi_derive::napi;
|
|
36
36
|
use serde_json;
|
|
37
37
|
use std::thread::sleep;
|
|
@@ -157,6 +157,115 @@ pub struct ResolveAudioFileResult {
|
|
|
157
157
|
pub format: String,
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/// Shared resolve body: load the audio item, pick a format, resolve the CDN URL
|
|
161
|
+
/// and request the audio key. Runs on `runtime()`; used by both the sync
|
|
162
|
+
/// `resolve_audio_file` and the async (off-main-thread) `resolve_audio_file_async`.
|
|
163
|
+
async fn do_resolve_audio_file(
|
|
164
|
+
session: Session,
|
|
165
|
+
spotify_uri: SpotifyUri,
|
|
166
|
+
track_id: SpotifyId,
|
|
167
|
+
bitrate_pref: Option<u32>,
|
|
168
|
+
) -> Result<ResolveAudioFileResult> {
|
|
169
|
+
let audio_item = AudioItem::get_file(&session, spotify_uri)
|
|
170
|
+
.await
|
|
171
|
+
.map_err(|e| Error::from_reason(format!("failed to load audio item: {e:?}")))?;
|
|
172
|
+
|
|
173
|
+
let select_format =
|
|
174
|
+
|files: &AudioFiles, bitrate: Option<u32>| -> Option<(AudioFileFormat, FileId)> {
|
|
175
|
+
let prefer = match bitrate {
|
|
176
|
+
Some(96) => {
|
|
177
|
+
vec![AudioFileFormat::OGG_VORBIS_96, AudioFileFormat::MP3_96]
|
|
178
|
+
}
|
|
179
|
+
Some(160) => {
|
|
180
|
+
vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
|
|
181
|
+
}
|
|
182
|
+
_ => vec![
|
|
183
|
+
AudioFileFormat::OGG_VORBIS_320,
|
|
184
|
+
AudioFileFormat::MP3_320,
|
|
185
|
+
AudioFileFormat::MP3_256,
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
for f in prefer {
|
|
189
|
+
if let Some(id) = files.get(&f) {
|
|
190
|
+
return Some((f, *id));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
files.iter().next().map(|(f, id)| (*f, *id))
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
let (format, file_id) = select_format(&audio_item.files, bitrate_pref)
|
|
197
|
+
.ok_or_else(|| Error::from_reason("no audio files available"))?;
|
|
198
|
+
|
|
199
|
+
let cdn = CdnUrl::new(file_id)
|
|
200
|
+
.resolve_audio(&session)
|
|
201
|
+
.await
|
|
202
|
+
.map_err(|e| Error::from_reason(format!("failed to resolve cdn url: {e:?}")))?;
|
|
203
|
+
let cdn_url = cdn
|
|
204
|
+
.try_get_urls()
|
|
205
|
+
.map_err(|e| Error::from_reason(format!("no cdn url available: {e:?}")))?
|
|
206
|
+
.into_iter()
|
|
207
|
+
.next()
|
|
208
|
+
.ok_or_else(|| Error::from_reason("no cdn url available"))?
|
|
209
|
+
.to_owned();
|
|
210
|
+
|
|
211
|
+
let key = session
|
|
212
|
+
.audio_key()
|
|
213
|
+
.request(track_id, file_id)
|
|
214
|
+
.await
|
|
215
|
+
.map_err(|e| Error::from_reason(format!("audio key unavailable: {e:?}")))?;
|
|
216
|
+
let key_hex = key.0.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
|
217
|
+
|
|
218
|
+
Ok(ResolveAudioFileResult {
|
|
219
|
+
cdn_url,
|
|
220
|
+
key_hex,
|
|
221
|
+
format: format!("{:?}", format),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Parse + validate a Spotify URI into the ids needed to resolve audio. Cheap
|
|
226
|
+
/// and synchronous; safe to run on the main thread before handing off to a
|
|
227
|
+
/// worker.
|
|
228
|
+
fn parse_resolve_target(uri: &str) -> Result<(SpotifyUri, SpotifyId)> {
|
|
229
|
+
if uri.is_empty() {
|
|
230
|
+
return Err(Error::from_reason("uri is required"));
|
|
231
|
+
}
|
|
232
|
+
let spotify_uri = SpotifyUri::from_uri(uri)
|
|
233
|
+
.map_err(|e| Error::from_reason(format!("invalid spotify uri: {:?}", e)))?;
|
|
234
|
+
let track_id: SpotifyId = (&spotify_uri)
|
|
235
|
+
.try_into()
|
|
236
|
+
.map_err(|e| Error::from_reason(format!("invalid spotify id: {e:?}")))?;
|
|
237
|
+
Ok((spotify_uri, track_id))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// libuv-threadpool task for `resolve_audio_file_async`: runs the blocking
|
|
241
|
+
/// `runtime().block_on(do_resolve_audio_file(..))` on a worker thread so the
|
|
242
|
+
/// Node event loop (and thus every other zone's audio pacing) is never stalled
|
|
243
|
+
/// by the synchronous CDN/key lookup.
|
|
244
|
+
pub struct ResolveAudioFileTask {
|
|
245
|
+
session: Session,
|
|
246
|
+
spotify_uri: SpotifyUri,
|
|
247
|
+
track_id: SpotifyId,
|
|
248
|
+
bitrate_pref: Option<u32>,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
impl Task for ResolveAudioFileTask {
|
|
252
|
+
type Output = ResolveAudioFileResult;
|
|
253
|
+
type JsValue = ResolveAudioFileResult;
|
|
254
|
+
|
|
255
|
+
fn compute(&mut self) -> Result<Self::Output> {
|
|
256
|
+
runtime().block_on(do_resolve_audio_file(
|
|
257
|
+
self.session.clone(),
|
|
258
|
+
self.spotify_uri.clone(),
|
|
259
|
+
self.track_id,
|
|
260
|
+
self.bitrate_pref,
|
|
261
|
+
))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
265
|
+
Ok(output)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
160
269
|
/// Result of a credentials login flow.
|
|
161
270
|
#[napi(object)]
|
|
162
271
|
pub struct CredentialsResult {
|
|
@@ -1375,75 +1484,32 @@ impl LibrespotSession {
|
|
|
1375
1484
|
/// decoding (Spotify prepends a ~167-byte header).
|
|
1376
1485
|
#[napi]
|
|
1377
1486
|
pub fn resolve_audio_file(&self, opts: DownloadTrackOpts) -> Result<ResolveAudioFileResult> {
|
|
1378
|
-
let
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
.
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
.map_err(|e| Error::from_reason(format!("invalid spotify id: {e:?}")))?;
|
|
1387
|
-
|
|
1388
|
-
let session = self.session.clone();
|
|
1389
|
-
let bitrate_pref = opts.bitrate;
|
|
1390
|
-
|
|
1391
|
-
runtime().block_on(async move {
|
|
1392
|
-
let audio_item = AudioItem::get_file(&session, spotify_uri.clone())
|
|
1393
|
-
.await
|
|
1394
|
-
.map_err(|e| Error::from_reason(format!("failed to load audio item: {e:?}")))?;
|
|
1395
|
-
|
|
1396
|
-
let select_format =
|
|
1397
|
-
|files: &AudioFiles, bitrate: Option<u32>| -> Option<(AudioFileFormat, FileId)> {
|
|
1398
|
-
let prefer = match bitrate {
|
|
1399
|
-
Some(96) => {
|
|
1400
|
-
vec![AudioFileFormat::OGG_VORBIS_96, AudioFileFormat::MP3_96]
|
|
1401
|
-
}
|
|
1402
|
-
Some(160) => {
|
|
1403
|
-
vec![AudioFileFormat::OGG_VORBIS_160, AudioFileFormat::MP3_160]
|
|
1404
|
-
}
|
|
1405
|
-
_ => vec![
|
|
1406
|
-
AudioFileFormat::OGG_VORBIS_320,
|
|
1407
|
-
AudioFileFormat::MP3_320,
|
|
1408
|
-
AudioFileFormat::MP3_256,
|
|
1409
|
-
],
|
|
1410
|
-
};
|
|
1411
|
-
for f in prefer {
|
|
1412
|
-
if let Some(id) = files.get(&f) {
|
|
1413
|
-
return Some((f, *id));
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
files.iter().next().map(|(f, id)| (*f, *id))
|
|
1417
|
-
};
|
|
1418
|
-
|
|
1419
|
-
let (format, file_id) = select_format(&audio_item.files, bitrate_pref)
|
|
1420
|
-
.ok_or_else(|| Error::from_reason("no audio files available"))?;
|
|
1421
|
-
|
|
1422
|
-
let cdn = CdnUrl::new(file_id)
|
|
1423
|
-
.resolve_audio(&session)
|
|
1424
|
-
.await
|
|
1425
|
-
.map_err(|e| Error::from_reason(format!("failed to resolve cdn url: {e:?}")))?;
|
|
1426
|
-
let cdn_url = cdn
|
|
1427
|
-
.try_get_urls()
|
|
1428
|
-
.map_err(|e| Error::from_reason(format!("no cdn url available: {e:?}")))?
|
|
1429
|
-
.into_iter()
|
|
1430
|
-
.next()
|
|
1431
|
-
.ok_or_else(|| Error::from_reason("no cdn url available"))?
|
|
1432
|
-
.to_owned();
|
|
1433
|
-
|
|
1434
|
-
let key = session
|
|
1435
|
-
.audio_key()
|
|
1436
|
-
.request(track_id, file_id)
|
|
1437
|
-
.await
|
|
1438
|
-
.map_err(|e| Error::from_reason(format!("audio key unavailable: {e:?}")))?;
|
|
1439
|
-
let key_hex = key.0.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
|
1487
|
+
let (spotify_uri, track_id) = parse_resolve_target(&opts.uri)?;
|
|
1488
|
+
runtime().block_on(do_resolve_audio_file(
|
|
1489
|
+
self.session.clone(),
|
|
1490
|
+
spotify_uri,
|
|
1491
|
+
track_id,
|
|
1492
|
+
opts.bitrate,
|
|
1493
|
+
))
|
|
1494
|
+
}
|
|
1440
1495
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1496
|
+
/// Async variant of `resolve_audio_file`: returns a Promise and runs the
|
|
1497
|
+
/// blocking CDN/key lookup on the libuv threadpool, so resolving a track
|
|
1498
|
+
/// never stalls the Node event loop (and other zones' audio pacing). Prefer
|
|
1499
|
+
/// this on the playback hot path; the sync version remains for simple/probe
|
|
1500
|
+
/// callers.
|
|
1501
|
+
#[napi(ts_return_type = "Promise<ResolveAudioFileResult>")]
|
|
1502
|
+
pub fn resolve_audio_file_async(
|
|
1503
|
+
&self,
|
|
1504
|
+
opts: DownloadTrackOpts,
|
|
1505
|
+
) -> Result<AsyncTask<ResolveAudioFileTask>> {
|
|
1506
|
+
let (spotify_uri, track_id) = parse_resolve_target(&opts.uri)?;
|
|
1507
|
+
Ok(AsyncTask::new(ResolveAudioFileTask {
|
|
1508
|
+
session: self.session.clone(),
|
|
1509
|
+
spotify_uri,
|
|
1510
|
+
track_id,
|
|
1511
|
+
bitrate_pref: opts.bitrate,
|
|
1512
|
+
}))
|
|
1447
1513
|
}
|
|
1448
1514
|
|
|
1449
1515
|
/// Download (stream) raw decrypted audio bytes for a track/episode.
|
|
@@ -1894,8 +1960,10 @@ fn start_connect_device_inner(
|
|
|
1894
1960
|
name: name.clone(),
|
|
1895
1961
|
device_type: DeviceType::Speaker,
|
|
1896
1962
|
is_group: false,
|
|
1897
|
-
// Start
|
|
1898
|
-
//
|
|
1963
|
+
// Start at full volume: the Player does not attenuate locally (NoOpVolume below),
|
|
1964
|
+
// so the PCM stays full-scale and the zone applies volume once on output. Volume
|
|
1965
|
+
// changes from the Spotify picker are still forwarded to the zone via VolumeChanged.
|
|
1966
|
+
// Spotify volume scale is 0..65535; use max to avoid a muted start.
|
|
1899
1967
|
initial_volume: u16::MAX,
|
|
1900
1968
|
disable_volume: false,
|
|
1901
1969
|
volume_steps: 64,
|
|
@@ -1903,9 +1971,16 @@ fn start_connect_device_inner(
|
|
|
1903
1971
|
};
|
|
1904
1972
|
|
|
1905
1973
|
let player_config = PlayerConfig::default();
|
|
1974
|
+
// The SoftMixer is still handed to the Spirc below so it tracks connect volume
|
|
1975
|
+
// state and keeps emitting VolumeChanged events (forwarded to the zone). But the
|
|
1976
|
+
// Player must NOT attenuate the PCM locally: volume is owned by the zone, which
|
|
1977
|
+
// applies it once on output. Feeding the SoftMixer's soft volume into the Player
|
|
1978
|
+
// here would double-attenuate (librespot volume × zone volume) whenever the
|
|
1979
|
+
// Spotify slider is moved. NoOpVolume keeps the PCM at full scale so the zone is
|
|
1980
|
+
// the sole attenuator while the Spotify picker still drives the zone volume.
|
|
1906
1981
|
let mixer = SoftMixer::open(MixerConfig::default())
|
|
1907
1982
|
.map_err(|e| Error::from_reason(format!("mixer init failed: {e}")))?;
|
|
1908
|
-
let volume_getter =
|
|
1983
|
+
let volume_getter: Box<dyn VolumeGetter + Send> = Box::new(NoOpVolume);
|
|
1909
1984
|
|
|
1910
1985
|
let (tx, mut rx) = mpsc::channel::<Bytes>(256);
|
|
1911
1986
|
|
package/src/types.ts
CHANGED
|
@@ -132,6 +132,12 @@ export interface LibrespotSession {
|
|
|
132
132
|
): StreamHandle;
|
|
133
133
|
/** Resolve CDN URL + AES key for a track without downloading audio. */
|
|
134
134
|
resolveAudioFile(opts: DownloadTrackOpts): ResolveAudioFileResult;
|
|
135
|
+
/**
|
|
136
|
+
* Async variant of {@link resolveAudioFile}: the blocking CDN/key lookup runs
|
|
137
|
+
* on the libuv threadpool, so it never stalls the Node event loop. Prefer this
|
|
138
|
+
* on the playback hot path.
|
|
139
|
+
*/
|
|
140
|
+
resolveAudioFileAsync(opts: DownloadTrackOpts): Promise<ResolveAudioFileResult>;
|
|
135
141
|
close(): Promise<void>;
|
|
136
142
|
}
|
|
137
143
|
|