@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-librespot",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Node.js bindings for librespot (Spotify Connect) via N-API with prebuild support.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/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 uri = opts.uri.clone();
1379
- if uri.is_empty() {
1380
- return Err(Error::from_reason("uri is required"));
1381
- }
1382
- let spotify_uri = SpotifyUri::from_uri(&uri)
1383
- .map_err(|e| Error::from_reason(format!("invalid spotify uri: {:?}", e)))?;
1384
- let track_id: SpotifyId = (&spotify_uri)
1385
- .try_into()
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
- Ok(ResolveAudioFileResult {
1442
- cdn_url,
1443
- key_hex,
1444
- format: format!("{:?}", format),
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 with full volume so we rely on zone-side volume control; we do not sync Spotify volume.
1898
- // Spotify volume scale is 0..65535; use max to avoid muted start.
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 = mixer.get_soft_volume();
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