@lox-audioserver/node-librespot 0.4.2 → 0.4.3
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/Cargo.lock +8 -0
- package/Cargo.toml +8 -6
- package/README.md +13 -1
- package/dist/index.js +3 -0
- package/dist/types.d.ts +11 -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 +4 -0
- package/src/lib.rs +93 -2
- package/src/types.ts +12 -0
package/Cargo.lock
CHANGED
|
@@ -1135,6 +1135,7 @@ dependencies = [
|
|
|
1135
1135
|
[[package]]
|
|
1136
1136
|
name = "librespot-audio"
|
|
1137
1137
|
version = "0.8.0"
|
|
1138
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1138
1139
|
dependencies = [
|
|
1139
1140
|
"aes",
|
|
1140
1141
|
"bytes",
|
|
@@ -1153,6 +1154,7 @@ dependencies = [
|
|
|
1153
1154
|
[[package]]
|
|
1154
1155
|
name = "librespot-connect"
|
|
1155
1156
|
version = "0.8.0"
|
|
1157
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1156
1158
|
dependencies = [
|
|
1157
1159
|
"futures-util",
|
|
1158
1160
|
"librespot-core",
|
|
@@ -1171,6 +1173,7 @@ dependencies = [
|
|
|
1171
1173
|
[[package]]
|
|
1172
1174
|
name = "librespot-core"
|
|
1173
1175
|
version = "0.8.0"
|
|
1176
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1174
1177
|
dependencies = [
|
|
1175
1178
|
"aes",
|
|
1176
1179
|
"base64 0.22.1",
|
|
@@ -1227,6 +1230,7 @@ dependencies = [
|
|
|
1227
1230
|
[[package]]
|
|
1228
1231
|
name = "librespot-discovery"
|
|
1229
1232
|
version = "0.8.0"
|
|
1233
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1230
1234
|
dependencies = [
|
|
1231
1235
|
"aes",
|
|
1232
1236
|
"base64 0.22.1",
|
|
@@ -1253,6 +1257,7 @@ dependencies = [
|
|
|
1253
1257
|
[[package]]
|
|
1254
1258
|
name = "librespot-metadata"
|
|
1255
1259
|
version = "0.8.0"
|
|
1260
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1256
1261
|
dependencies = [
|
|
1257
1262
|
"async-trait",
|
|
1258
1263
|
"bytes",
|
|
@@ -1269,6 +1274,7 @@ dependencies = [
|
|
|
1269
1274
|
[[package]]
|
|
1270
1275
|
name = "librespot-oauth"
|
|
1271
1276
|
version = "0.8.0"
|
|
1277
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1272
1278
|
dependencies = [
|
|
1273
1279
|
"log",
|
|
1274
1280
|
"oauth2",
|
|
@@ -1281,6 +1287,7 @@ dependencies = [
|
|
|
1281
1287
|
[[package]]
|
|
1282
1288
|
name = "librespot-playback"
|
|
1283
1289
|
version = "0.8.0"
|
|
1290
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1284
1291
|
dependencies = [
|
|
1285
1292
|
"form_urlencoded",
|
|
1286
1293
|
"futures-util",
|
|
@@ -1301,6 +1308,7 @@ dependencies = [
|
|
|
1301
1308
|
[[package]]
|
|
1302
1309
|
name = "librespot-protocol"
|
|
1303
1310
|
version = "0.8.0"
|
|
1311
|
+
source = "git+https://github.com/librespot-org/librespot?rev=33bf3a77ed4b549df67e8347d7d6e55b007b3ec2#33bf3a77ed4b549df67e8347d7d6e55b007b3ec2"
|
|
1304
1312
|
dependencies = [
|
|
1305
1313
|
"protobuf",
|
|
1306
1314
|
"protobuf-codegen",
|
package/Cargo.toml
CHANGED
|
@@ -15,18 +15,20 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
|
|
15
15
|
futures = "0.3"
|
|
16
16
|
bytes = "1"
|
|
17
17
|
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
|
|
18
|
-
|
|
18
|
+
# Pinned to a specific upstream commit (v0.8.0 + 14 dev commits) so source
|
|
19
|
+
# builds are reproducible without a local ./librespot-dev checkout. See issue #4.
|
|
20
|
+
librespot-core = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false, features = [
|
|
19
21
|
"rustls-tls-native-roots",
|
|
20
22
|
] }
|
|
21
|
-
librespot-audio = {
|
|
23
|
+
librespot-audio = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false, features = [
|
|
22
24
|
"rustls-tls-native-roots",
|
|
23
25
|
] }
|
|
24
|
-
librespot-playback = {
|
|
25
|
-
librespot-discovery = {
|
|
26
|
+
librespot-playback = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false }
|
|
27
|
+
librespot-discovery = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false, features = [
|
|
26
28
|
"with-libmdns",
|
|
27
29
|
] }
|
|
28
|
-
librespot-connect = {
|
|
29
|
-
librespot-metadata = {
|
|
30
|
+
librespot-connect = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false }
|
|
31
|
+
librespot-metadata = { git = "https://github.com/librespot-org/librespot", rev = "33bf3a77ed4b549df67e8347d7d6e55b007b3ec2", default-features = false }
|
|
30
32
|
serde_json = "1"
|
|
31
33
|
tokio-stream = "0.1"
|
|
32
34
|
log = { version = "0.4", features = ["std"] }
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Used by [lox-audioserver](https://github.com/rudyberends/lox-audioserver) to han
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
- Stream a Spotify track/episode to PCM buffers (`streamTrack`) using a Web API **access token**.
|
|
9
|
+
- Pull raw decrypted audio bytes (Ogg/MP3) for a track/episode without decoding (`downloadTrack`).
|
|
10
|
+
- Resolve a track's signed CDN URL + AES audio key without downloading, so the caller can fetch (HTTP Range) and decrypt itself (`resolveAudioFile`).
|
|
9
11
|
- Host a Spotify Connect endpoint with a Web API access token + your own Spotify app client id (`startConnectDeviceWithToken`).
|
|
10
12
|
- No disk cache required; everything stays in-memory.
|
|
11
13
|
- Starts Connect hosts at max volume (volume control stays on the consumer side).
|
|
@@ -17,7 +19,9 @@ npm install
|
|
|
17
19
|
```
|
|
18
20
|
|
|
19
21
|
## Build
|
|
20
|
-
Requires Rust (stable) and `@napi-rs/cli` (installed via devDependencies).
|
|
22
|
+
Requires Rust (stable) and `@napi-rs/cli` (installed via devDependencies). The
|
|
23
|
+
`librespot-*` crates are pulled as pinned git dependencies (a fixed upstream
|
|
24
|
+
commit), so a from-source build works on a fresh clone with no local checkout.
|
|
21
25
|
```bash
|
|
22
26
|
npm run build # release build
|
|
23
27
|
# or
|
|
@@ -89,6 +93,14 @@ const download = await downloadTrack(
|
|
|
89
93
|
// download.stop() to cancel
|
|
90
94
|
// Promise rejects on initial errors (invalid URI, unavailable track, key/file fetch failure).
|
|
91
95
|
|
|
96
|
+
// Or resolve the CDN url + AES key without downloading (you fetch + decrypt yourself):
|
|
97
|
+
const { cdnUrl, keyHex, format } = session.resolveAudioFile({ uri: 'spotify:track:...', bitrate: 320 });
|
|
98
|
+
// - cdnUrl: signed, expiring CDN url for the encrypted file (GET with Range)
|
|
99
|
+
// - keyHex: 16-byte AES-128 key, hex-encoded
|
|
100
|
+
// - format: e.g. "OGG_VORBIS_320" or "MP3_320"
|
|
101
|
+
// Decrypt with AES-128-CTR using the fixed Spotify audio IV (counter = byteOffset / 16).
|
|
102
|
+
// For OGG, strip up to the first 'OggS' page (Spotify prepends a ~167-byte header).
|
|
103
|
+
|
|
92
104
|
handle.stop();
|
|
93
105
|
```
|
|
94
106
|
|
package/dist/index.js
CHANGED
|
@@ -92,6 +92,9 @@ function wrapSession(session) {
|
|
|
92
92
|
const handle = session.streamTrack(nativeOpts, onChunk, onEvent, onLog);
|
|
93
93
|
return wrapStreamHandle(handle);
|
|
94
94
|
},
|
|
95
|
+
resolveAudioFile: (opts) => {
|
|
96
|
+
return session.resolveAudioFile({ uri: opts.uri, bitrate: opts.bitrate });
|
|
97
|
+
},
|
|
95
98
|
close: () => session.close(),
|
|
96
99
|
};
|
|
97
100
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface DownloadTrackOpts {
|
|
|
23
23
|
uri: string;
|
|
24
24
|
bitrate?: number;
|
|
25
25
|
}
|
|
26
|
+
/** Result of resolving a track's CDN location + decryption key (no download). */
|
|
27
|
+
export interface ResolveAudioFileResult {
|
|
28
|
+
/** Signed, expiring CDN URL for the encrypted audio file (GET with Range). */
|
|
29
|
+
cdnUrl: string;
|
|
30
|
+
/** 16-byte AES-128 audio key, hex-encoded (lowercase, 32 chars). */
|
|
31
|
+
keyHex: string;
|
|
32
|
+
/** Chosen Spotify audio format, e.g. "OGG_VORBIS_320" or "MP3_320". */
|
|
33
|
+
format: string;
|
|
34
|
+
}
|
|
26
35
|
/** Result of a credentials login flow. */
|
|
27
36
|
export interface CredentialsResult {
|
|
28
37
|
username: string;
|
|
@@ -76,6 +85,8 @@ export interface ConnectHandle {
|
|
|
76
85
|
/** Handle to a librespot session. */
|
|
77
86
|
export interface LibrespotSession {
|
|
78
87
|
streamTrack(opts: StreamTrackOpts, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): StreamHandle;
|
|
88
|
+
/** Resolve CDN URL + AES key for a track without downloading audio. */
|
|
89
|
+
resolveAudioFile(opts: DownloadTrackOpts): ResolveAudioFileResult;
|
|
79
90
|
close(): Promise<void>;
|
|
80
91
|
}
|
|
81
92
|
export interface StreamHandle {
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
StreamTrackOpts,
|
|
13
13
|
DownloadTrackOpts,
|
|
14
14
|
DownloadHandle,
|
|
15
|
+
ResolveAudioFileResult,
|
|
15
16
|
} from './types';
|
|
16
17
|
|
|
17
18
|
function detectLibc(): 'gnu' | 'musl' {
|
|
@@ -145,6 +146,9 @@ function wrapSession(session: LibrespotSession) {
|
|
|
145
146
|
const handle = (session as any).streamTrack(nativeOpts, onChunk, onEvent, onLog);
|
|
146
147
|
return wrapStreamHandle(handle);
|
|
147
148
|
},
|
|
149
|
+
resolveAudioFile: (opts: DownloadTrackOpts): ResolveAudioFileResult => {
|
|
150
|
+
return (session as any).resolveAudioFile({ uri: opts.uri, bitrate: opts.bitrate });
|
|
151
|
+
},
|
|
148
152
|
close: () => session.close(),
|
|
149
153
|
};
|
|
150
154
|
}
|
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,
|
|
16
|
-
spotify_id::FileId, SpotifyId, SpotifyUri,
|
|
15
|
+
authentication::Credentials, cache::Cache, cdn_url::CdnUrl, config::SessionConfig,
|
|
16
|
+
session::Session, spotify_id::FileId, SpotifyId, SpotifyUri,
|
|
17
17
|
};
|
|
18
18
|
use librespot_discovery::{DeviceType, Discovery};
|
|
19
19
|
use librespot_metadata::audio::{AudioFileFormat, AudioFiles, AudioItem};
|
|
@@ -144,6 +144,19 @@ pub struct DownloadTrackOpts {
|
|
|
144
144
|
pub bitrate: Option<u32>,
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/// Result of resolving a track's CDN location + decryption key, without
|
|
148
|
+
/// downloading or decrypting any audio. Lets the caller fetch the signed
|
|
149
|
+
/// (expiring) CDN URL directly with HTTP Range and AES-128-CTR decrypt itself.
|
|
150
|
+
#[napi(object)]
|
|
151
|
+
pub struct ResolveAudioFileResult {
|
|
152
|
+
/// Signed, expiring CDN URL for the encrypted audio file (GET with Range).
|
|
153
|
+
pub cdn_url: String,
|
|
154
|
+
/// 16-byte AES-128 audio key, hex-encoded (lowercase, 32 chars).
|
|
155
|
+
pub key_hex: String,
|
|
156
|
+
/// Chosen Spotify audio format, e.g. "OGG_VORBIS_320" or "MP3_320".
|
|
157
|
+
pub format: String,
|
|
158
|
+
}
|
|
159
|
+
|
|
147
160
|
/// Result of a credentials login flow.
|
|
148
161
|
#[napi(object)]
|
|
149
162
|
pub struct CredentialsResult {
|
|
@@ -1355,6 +1368,84 @@ impl LibrespotSession {
|
|
|
1355
1368
|
})
|
|
1356
1369
|
}
|
|
1357
1370
|
|
|
1371
|
+
/// Resolve a track's signed CDN URL + AES audio key WITHOUT downloading or
|
|
1372
|
+
/// decrypting any audio. The caller fetches the CDN URL directly (HTTP Range
|
|
1373
|
+
/// supported) and decrypts with AES-128-CTR (fixed Spotify IV, counter =
|
|
1374
|
+
/// byte_offset / 16). For OGG, strip up to the first `OggS` page before
|
|
1375
|
+
/// decoding (Spotify prepends a ~167-byte header).
|
|
1376
|
+
#[napi]
|
|
1377
|
+
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>();
|
|
1440
|
+
|
|
1441
|
+
Ok(ResolveAudioFileResult {
|
|
1442
|
+
cdn_url,
|
|
1443
|
+
key_hex,
|
|
1444
|
+
format: format!("{:?}", format),
|
|
1445
|
+
})
|
|
1446
|
+
})
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1358
1449
|
/// Download (stream) raw decrypted audio bytes for a track/episode.
|
|
1359
1450
|
#[napi]
|
|
1360
1451
|
pub fn download_track(
|
package/src/types.ts
CHANGED
|
@@ -28,6 +28,16 @@ export interface DownloadTrackOpts {
|
|
|
28
28
|
bitrate?: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Result of resolving a track's CDN location + decryption key (no download). */
|
|
32
|
+
export interface ResolveAudioFileResult {
|
|
33
|
+
/** Signed, expiring CDN URL for the encrypted audio file (GET with Range). */
|
|
34
|
+
cdnUrl: string;
|
|
35
|
+
/** 16-byte AES-128 audio key, hex-encoded (lowercase, 32 chars). */
|
|
36
|
+
keyHex: string;
|
|
37
|
+
/** Chosen Spotify audio format, e.g. "OGG_VORBIS_320" or "MP3_320". */
|
|
38
|
+
format: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
/** Result of a credentials login flow. */
|
|
32
42
|
export interface CredentialsResult {
|
|
33
43
|
username: string;
|
|
@@ -120,6 +130,8 @@ export interface LibrespotSession {
|
|
|
120
130
|
onEvent?: (event: ConnectEvent) => void,
|
|
121
131
|
onLog?: (event: LogEvent) => void,
|
|
122
132
|
): StreamHandle;
|
|
133
|
+
/** Resolve CDN URL + AES key for a track without downloading audio. */
|
|
134
|
+
resolveAudioFile(opts: DownloadTrackOpts): ResolveAudioFileResult;
|
|
123
135
|
close(): Promise<void>;
|
|
124
136
|
}
|
|
125
137
|
|