@lox-audioserver/node-librespot 0.4.1 → 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.1",
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(
@@ -1865,12 +1956,21 @@ fn start_connect_device_inner(
1865
1956
  let player = Player::new(player_config, session.clone(), volume_getter, sink_builder);
1866
1957
  // Clone log_tsfn before it is moved into the player-event spawn below.
1867
1958
  let log_tsfn_for_disc = log_tsfn.clone();
1959
+ // [issue #252] Bumped by the player-event observer on signals that
1960
+ // prove audio is flowing (`playing` / `position_correction`). Read by
1961
+ // the log observer further down to suppress transient `audio_key_error`
1962
+ // events that fire while spirc is already recovering from an
1963
+ // account-side dealer-disconnect burst. A permanent audio-key fault
1964
+ // never produces a healthy event, so the error still surfaces after
1965
+ // the grace window.
1966
+ let healthy_seq = Arc::new(AtomicU64::new(0));
1868
1967
  // Forward player events to JS if requested.
1869
1968
  if let Some(tsfn_ev) = event_tsfn.clone() {
1870
1969
  let mut ev_rx = player.get_player_event_channel();
1871
1970
  let session_for_events = session.clone();
1872
1971
  let device_id_for_events = device_id.clone();
1873
1972
  let session_id_for_events = session_id.clone();
1973
+ let healthy_seq_for_events = healthy_seq.clone();
1874
1974
  let last_pcm_for_health = last_pcm_at.clone();
1875
1975
  let stop_flag_for_health = stop_flag_for_block.clone();
1876
1976
  let device_id_for_health = device_id.clone();
@@ -2250,6 +2350,13 @@ fn start_connect_device_inner(
2250
2350
  _ => {}
2251
2351
  }
2252
2352
 
2353
+ // [issue #252] Signal "audio is actually flowing" to the
2354
+ // log observer. Excludes `paused` because a paused stream
2355
+ // can't prove the dealer/session is healthy.
2356
+ if matches!(payload.r#type.as_str(), "playing" | "position_correction") {
2357
+ healthy_seq_for_events.fetch_add(1, Ordering::AcqRel);
2358
+ }
2359
+
2253
2360
  let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2254
2361
  }
2255
2362
  });
@@ -2265,6 +2372,16 @@ fn start_connect_device_inner(
2265
2372
  let stop_flag_for_logs = stop_flag_for_block.clone();
2266
2373
  let device_id_for_logs = device_id.clone();
2267
2374
  let session_id_for_logs = session_id.clone();
2375
+ let healthy_seq_for_logs = healthy_seq.clone();
2376
+ // [issue #252] Grace window before surfacing audio_key_error to JS.
2377
+ // Account-side Spotify dealer-disconnect bursts cause every
2378
+ // offload=false zone to emit a single audio-key-timeout log line,
2379
+ // while spirc reconnects internally within seconds. Without this
2380
+ // delay every burst trips the per-zone cooldown in
2381
+ // SpotifyConnectInstance and produces ~5 minutes of unplayable
2382
+ // state per zone. 5s is well above typical spirc recovery time
2383
+ // and well below the cooldown window.
2384
+ const AUDIO_KEY_GRACE_MS: u64 = 5000;
2268
2385
  runtime().spawn(async move {
2269
2386
  while let Some(event) = log_rx.recv().await {
2270
2387
  if stop_flag_for_logs.load(Ordering::Acquire) {
@@ -2322,7 +2439,28 @@ fn start_connect_device_inner(
2322
2439
  metric_message: None,
2323
2440
  credentials_json: None,
2324
2441
  };
2325
- let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2442
+ // [issue #252] Defer the emit by AUDIO_KEY_GRACE_MS and
2443
+ // suppress it if a healthy event arrived in the meantime
2444
+ // (spirc finished its internal reconnect). Re-arm
2445
+ // `error_sent` on suppression so a *later* fault in the
2446
+ // same session can still surface.
2447
+ let baseline = healthy_seq_for_logs.load(Ordering::Acquire);
2448
+ let healthy_seq_for_check = healthy_seq_for_logs.clone();
2449
+ let stop_flag_for_grace = stop_flag_for_logs.clone();
2450
+ let error_sent_for_reset = error_sent.clone();
2451
+ let tsfn_ev_for_grace = tsfn_ev.clone();
2452
+ runtime().spawn(async move {
2453
+ tokio::time::sleep(Duration::from_millis(AUDIO_KEY_GRACE_MS)).await;
2454
+ if stop_flag_for_grace.load(Ordering::Acquire) {
2455
+ return;
2456
+ }
2457
+ if healthy_seq_for_check.load(Ordering::Acquire) != baseline {
2458
+ error_sent_for_reset.store(false, Ordering::Release);
2459
+ return;
2460
+ }
2461
+ let _ = tsfn_ev_for_grace
2462
+ .call(payload, ThreadsafeFunctionCallMode::NonBlocking);
2463
+ });
2326
2464
  }
2327
2465
  });
2328
2466
  }
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