@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 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
- librespot-core = { path = "./librespot-dev/core", default-features = false, features = [
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 = { path = "./librespot-dev/audio", default-features = false, features = [
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 = { path = "./librespot-dev/playback", default-features = false }
25
- librespot-discovery = { path = "./librespot-dev/discovery", default-features = false, features = [
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 = { path = "./librespot-dev/connect", default-features = false }
29
- librespot-metadata = { path = "./librespot-dev/metadata", default-features = false }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-librespot",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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
@@ -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, config::SessionConfig, session::Session,
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