@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 +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 +136 -70
- 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.
|
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
|
|