@lox-audioserver/node-librespot 0.4.4 → 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.4",
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.
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