@lox-audioserver/node-librespot 0.3.6 → 0.4.1

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.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ConnectHandle, ConnectEvent, CreateSessionOpts, CredentialsResult, LibrespotSession, LogEvent, DownloadTrackOpts, DownloadHandle } from './types';
2
2
  declare const native: {
3
3
  createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
4
- createSessionWithCredentials(credentialsPath: string, deviceName?: string | null): Promise<LibrespotSession>;
4
+ createSessionWithCredentials(credentialsPath: string, deviceName?: string | null, cacheDir?: string | null, cacheSizeLimitMb?: number | null): Promise<LibrespotSession>;
5
5
  setLogLevel(level: string): void;
6
6
  loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
7
7
  downloadTrack(opts: DownloadTrackOpts, onChunk: (chunk: Buffer) => void, onLog?: (event: LogEvent) => void): Promise<DownloadHandle>;
@@ -11,7 +11,7 @@ declare const native: {
11
11
  startConnectDeviceWithToken(accessToken: string, clientId: string | undefined, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
12
12
  };
13
13
  export declare function createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
14
- export declare function createSessionWithCredentials(credentialsPathOrJson: string, deviceName?: string | null): Promise<LibrespotSession>;
14
+ export declare function createSessionWithCredentials(credentialsPathOrJson: string, deviceName?: string | null, cacheDir?: string | null, cacheSizeLimitMb?: number | null): Promise<LibrespotSession>;
15
15
  export declare function loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
16
16
  export declare function startZeroconfLogin(deviceId: string, name?: string, timeoutMs?: number): Promise<CredentialsResult>;
17
17
  export declare function startConnectDevice(credentialsPath: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
package/dist/index.js CHANGED
@@ -100,12 +100,14 @@ function createSession(opts) {
100
100
  accessToken: opts.accessToken ?? opts.access_token,
101
101
  clientId: opts.clientId ?? opts.client_id,
102
102
  deviceName: opts.deviceName ?? opts.device_name,
103
+ cacheDir: opts.cacheDir,
104
+ cacheSizeLimitMb: opts.cacheSizeLimitMb,
103
105
  };
104
106
  return native.createSession(nativeOpts).then((sess) => wrapSession(sess));
105
107
  }
106
- function createSessionWithCredentials(credentialsPathOrJson, deviceName) {
108
+ function createSessionWithCredentials(credentialsPathOrJson, deviceName, cacheDir, cacheSizeLimitMb) {
107
109
  return native
108
- .createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null)
110
+ .createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null, cacheDir ?? null, cacheSizeLimitMb ?? null)
109
111
  .then((sess) => wrapSession(sess));
110
112
  }
111
113
  function loginWithAccessToken(accessToken, deviceName) {
package/dist/types.d.ts CHANGED
@@ -3,6 +3,12 @@ export interface CreateSessionOpts {
3
3
  accessToken?: string;
4
4
  clientId?: string;
5
5
  deviceName?: string;
6
+ /** Directory for the audio file cache. When set, decoded audio is stored so
7
+ * subsequent plays of the same track skip the CDN download. */
8
+ cacheDir?: string;
9
+ /** Maximum size of the audio cache in megabytes. Only meaningful when
10
+ * `cacheDir` is set. Omit for no limit. */
11
+ cacheSizeLimitMb?: number;
6
12
  }
7
13
  /** Options for streaming a track. */
8
14
  export interface StreamTrackOpts {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-librespot",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
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
@@ -70,6 +70,8 @@ const native = require(resolveNativeBinding()) as {
70
70
  createSessionWithCredentials(
71
71
  credentialsPath: string,
72
72
  deviceName?: string | null,
73
+ cacheDir?: string | null,
74
+ cacheSizeLimitMb?: number | null,
73
75
  ): Promise<LibrespotSession>;
74
76
  setLogLevel(level: string): void;
75
77
  loginWithAccessToken(
@@ -148,20 +150,24 @@ function wrapSession(session: LibrespotSession) {
148
150
  }
149
151
 
150
152
  export function createSession(opts: CreateSessionOpts): Promise<LibrespotSession> {
151
- const nativeOpts = {
153
+ const nativeOpts: CreateSessionOpts = {
152
154
  accessToken: (opts as any).accessToken ?? (opts as any).access_token,
153
155
  clientId: (opts as any).clientId ?? (opts as any).client_id,
154
156
  deviceName: (opts as any).deviceName ?? (opts as any).device_name,
157
+ cacheDir: opts.cacheDir,
158
+ cacheSizeLimitMb: opts.cacheSizeLimitMb,
155
159
  };
156
- return native.createSession(nativeOpts as CreateSessionOpts).then((sess) => wrapSession(sess) as any);
160
+ return native.createSession(nativeOpts).then((sess) => wrapSession(sess) as any);
157
161
  }
158
162
 
159
163
  export function createSessionWithCredentials(
160
164
  credentialsPathOrJson: string,
161
165
  deviceName?: string | null,
166
+ cacheDir?: string | null,
167
+ cacheSizeLimitMb?: number | null,
162
168
  ): Promise<LibrespotSession> {
163
169
  return native
164
- .createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null)
170
+ .createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null, cacheDir ?? null, cacheSizeLimitMb ?? null)
165
171
  .then((sess) => wrapSession(sess) as any);
166
172
  }
167
173
 
package/src/lib.rs CHANGED
@@ -120,6 +120,12 @@ pub struct CreateSessionOpts {
120
120
  pub access_token: Option<String>,
121
121
  pub client_id: Option<String>,
122
122
  pub device_name: Option<String>,
123
+ /// Directory for the librespot audio file cache. When set, decoded audio
124
+ /// is written here so subsequent plays avoid a CDN round-trip.
125
+ pub cache_dir: Option<String>,
126
+ /// Maximum size of the audio cache in megabytes. Only used when
127
+ /// `cache_dir` is set. None means no size limit is enforced.
128
+ pub cache_size_limit_mb: Option<u32>,
123
129
  }
124
130
 
125
131
  /// Options for streaming a track.
@@ -507,6 +513,11 @@ impl Sink for ChannelSink {
507
513
  }
508
514
 
509
515
  fn stop(&mut self) -> SinkResult<()> {
516
+ // Reset pacing so the next start() (e.g. after Connect pause) is paced from now,
517
+ // not from the original stream_start. Without this the post-resume catch-up logic
518
+ // forwards minutes of decoded PCM at full speed.
519
+ self.start = None;
520
+ self.expected_elapsed = Duration::from_millis(0);
510
521
  Ok(())
511
522
  }
512
523
 
@@ -600,10 +611,20 @@ impl Sink for ChannelSink {
600
611
  if self.tx.try_send(bytes).is_err() {
601
612
  // Drop chunk if JS side is backpressured to avoid blocking the player thread.
602
613
  }
603
- let start = self.start.get_or_insert_with(Instant::now);
604
- self.expected_elapsed += duration;
605
- let target = *start + self.expected_elapsed;
606
614
  let now = Instant::now();
615
+ // If wall-clock has drifted far ahead of our expected timeline (a pause that didn't
616
+ // route through stop()/start(), a decoder stall, etc.), rebase pacing onto `now`.
617
+ // Otherwise `sleep_dur` saturates to 0 for the duration of the gap and the sink
618
+ // dumps the catch-up backlog at full speed downstream.
619
+ if let Some(start) = self.start {
620
+ if now > start + self.expected_elapsed + Duration::from_millis(200) {
621
+ self.start = None;
622
+ self.expected_elapsed = Duration::from_millis(0);
623
+ }
624
+ }
625
+ let start = *self.start.get_or_insert(now);
626
+ self.expected_elapsed += duration;
627
+ let target = start + self.expected_elapsed;
607
628
  let sleep_dur = target.saturating_duration_since(now);
608
629
  if !sleep_dur.is_zero() {
609
630
  sleep(sleep_dur);
@@ -1515,6 +1536,29 @@ impl LibrespotSession {
1515
1536
  }
1516
1537
  }
1517
1538
 
1539
+ /// Build a librespot `Cache` for audio file storage.
1540
+ /// Returns `None` if `cache_dir` is absent, the directory cannot be created,
1541
+ /// or `Cache::new` fails — in which case streaming still works without cache.
1542
+ fn build_audio_cache(cache_dir: Option<&str>, cache_size_limit_mb: Option<u32>) -> Option<Cache> {
1543
+ let dir = cache_dir?;
1544
+ if dir.trim().is_empty() {
1545
+ return None;
1546
+ }
1547
+ let path = std::path::Path::new(dir);
1548
+ if let Err(e) = fs::create_dir_all(path) {
1549
+ eprintln!("[lox-librespot] could not create cache dir {dir}: {e}");
1550
+ return None;
1551
+ }
1552
+ let size_limit = cache_size_limit_mb.map(|mb| mb as u64 * 1024 * 1024);
1553
+ Cache::new(
1554
+ Some(path),
1555
+ None::<&std::path::Path>,
1556
+ None::<&std::path::Path>,
1557
+ size_limit,
1558
+ )
1559
+ .ok()
1560
+ }
1561
+
1518
1562
  /// Create a session using a Web API access token (client id optional via opts or env).
1519
1563
  #[napi]
1520
1564
  pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession> {
@@ -1543,7 +1587,8 @@ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession>
1543
1587
  }
1544
1588
  }
1545
1589
 
1546
- let session = Session::new(session_config, None);
1590
+ let cache = build_audio_cache(opts.cache_dir.as_deref(), opts.cache_size_limit_mb);
1591
+ let session = Session::new(session_config, cache);
1547
1592
  session
1548
1593
  .connect(credentials, false)
1549
1594
  .await
@@ -1565,6 +1610,8 @@ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession>
1565
1610
  pub async fn create_session_with_credentials(
1566
1611
  credentials_path: String,
1567
1612
  device_name: Option<String>,
1613
+ cache_dir: Option<String>,
1614
+ cache_size_limit_mb: Option<u32>,
1568
1615
  ) -> Result<LibrespotSession> {
1569
1616
  if credentials_path.trim().is_empty() {
1570
1617
  return Err(Error::from_reason("credentials payload is required"));
@@ -1595,7 +1642,8 @@ pub async fn create_session_with_credentials(
1595
1642
  }
1596
1643
  }
1597
1644
 
1598
- let session = Session::new(session_config, None);
1645
+ let cache = build_audio_cache(cache_dir.as_deref(), cache_size_limit_mb);
1646
+ let session = Session::new(session_config, cache);
1599
1647
  session
1600
1648
  .connect(credentials, false)
1601
1649
  .await
package/src/types.ts CHANGED
@@ -5,6 +5,12 @@ export interface CreateSessionOpts {
5
5
  accessToken?: string;
6
6
  clientId?: string;
7
7
  deviceName?: string;
8
+ /** Directory for the audio file cache. When set, decoded audio is stored so
9
+ * subsequent plays of the same track skip the CDN download. */
10
+ cacheDir?: string;
11
+ /** Maximum size of the audio cache in megabytes. Only meaningful when
12
+ * `cacheDir` is set. Omit for no limit. */
13
+ cacheSizeLimitMb?: number;
8
14
  }
9
15
 
10
16
  /** Options for streaming a track. */