@lox-audioserver/node-librespot 0.3.5 → 0.4.0
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 +2 -2
- package/dist/index.js +4 -2
- 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/src/index.ts +9 -3
- package/src/lib.rs +107 -2
- package/src/types.ts +6 -0
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
|
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.
|
|
@@ -1515,6 +1521,29 @@ impl LibrespotSession {
|
|
|
1515
1521
|
}
|
|
1516
1522
|
}
|
|
1517
1523
|
|
|
1524
|
+
/// Build a librespot `Cache` for audio file storage.
|
|
1525
|
+
/// Returns `None` if `cache_dir` is absent, the directory cannot be created,
|
|
1526
|
+
/// or `Cache::new` fails — in which case streaming still works without cache.
|
|
1527
|
+
fn build_audio_cache(cache_dir: Option<&str>, cache_size_limit_mb: Option<u32>) -> Option<Cache> {
|
|
1528
|
+
let dir = cache_dir?;
|
|
1529
|
+
if dir.trim().is_empty() {
|
|
1530
|
+
return None;
|
|
1531
|
+
}
|
|
1532
|
+
let path = std::path::Path::new(dir);
|
|
1533
|
+
if let Err(e) = fs::create_dir_all(path) {
|
|
1534
|
+
eprintln!("[lox-librespot] could not create cache dir {dir}: {e}");
|
|
1535
|
+
return None;
|
|
1536
|
+
}
|
|
1537
|
+
let size_limit = cache_size_limit_mb.map(|mb| mb as u64 * 1024 * 1024);
|
|
1538
|
+
Cache::new(
|
|
1539
|
+
Some(path),
|
|
1540
|
+
None::<&std::path::Path>,
|
|
1541
|
+
None::<&std::path::Path>,
|
|
1542
|
+
size_limit,
|
|
1543
|
+
)
|
|
1544
|
+
.ok()
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1518
1547
|
/// Create a session using a Web API access token (client id optional via opts or env).
|
|
1519
1548
|
#[napi]
|
|
1520
1549
|
pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession> {
|
|
@@ -1543,7 +1572,8 @@ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession>
|
|
|
1543
1572
|
}
|
|
1544
1573
|
}
|
|
1545
1574
|
|
|
1546
|
-
let
|
|
1575
|
+
let cache = build_audio_cache(opts.cache_dir.as_deref(), opts.cache_size_limit_mb);
|
|
1576
|
+
let session = Session::new(session_config, cache);
|
|
1547
1577
|
session
|
|
1548
1578
|
.connect(credentials, false)
|
|
1549
1579
|
.await
|
|
@@ -1565,6 +1595,8 @@ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession>
|
|
|
1565
1595
|
pub async fn create_session_with_credentials(
|
|
1566
1596
|
credentials_path: String,
|
|
1567
1597
|
device_name: Option<String>,
|
|
1598
|
+
cache_dir: Option<String>,
|
|
1599
|
+
cache_size_limit_mb: Option<u32>,
|
|
1568
1600
|
) -> Result<LibrespotSession> {
|
|
1569
1601
|
if credentials_path.trim().is_empty() {
|
|
1570
1602
|
return Err(Error::from_reason("credentials payload is required"));
|
|
@@ -1595,7 +1627,8 @@ pub async fn create_session_with_credentials(
|
|
|
1595
1627
|
}
|
|
1596
1628
|
}
|
|
1597
1629
|
|
|
1598
|
-
let
|
|
1630
|
+
let cache = build_audio_cache(cache_dir.as_deref(), cache_size_limit_mb);
|
|
1631
|
+
let session = Session::new(session_config, cache);
|
|
1599
1632
|
session
|
|
1600
1633
|
.connect(credentials, false)
|
|
1601
1634
|
.await
|
|
@@ -2207,6 +2240,78 @@ fn start_connect_device_inner(
|
|
|
2207
2240
|
});
|
|
2208
2241
|
}
|
|
2209
2242
|
|
|
2243
|
+
// Log observer: detect audio_key_error and decoder errors from librespot log output.
|
|
2244
|
+
// This mirrors the same pattern used in stream_track to ensure the JS layer receives
|
|
2245
|
+
// error events and can trigger recovery (e.g. re-authentication, skip to next track).
|
|
2246
|
+
if let Some(tsfn_ev) = event_tsfn.clone() {
|
|
2247
|
+
let mut log_rx = subscribe_log_events();
|
|
2248
|
+
let error_sent = Arc::new(AtomicBool::new(false));
|
|
2249
|
+
let decoder_metric_sent = Arc::new(AtomicBool::new(false));
|
|
2250
|
+
let stop_flag_for_logs = stop_flag_for_block.clone();
|
|
2251
|
+
let device_id_for_logs = device_id.clone();
|
|
2252
|
+
let session_id_for_logs = session_id.clone();
|
|
2253
|
+
runtime().spawn(async move {
|
|
2254
|
+
while let Some(event) = log_rx.recv().await {
|
|
2255
|
+
if stop_flag_for_logs.load(Ordering::Acquire) {
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
2258
|
+
if is_decoder_error(&event) {
|
|
2259
|
+
if !decoder_metric_sent.swap(true, Ordering::AcqRel) {
|
|
2260
|
+
let payload = ConnectEvent {
|
|
2261
|
+
r#type: "metric".into(),
|
|
2262
|
+
device_id: Some(device_id_for_logs.clone()),
|
|
2263
|
+
session_id: Some(session_id_for_logs.clone()),
|
|
2264
|
+
track_id: None,
|
|
2265
|
+
uri: None,
|
|
2266
|
+
title: None,
|
|
2267
|
+
artist: None,
|
|
2268
|
+
album: None,
|
|
2269
|
+
duration_ms: None,
|
|
2270
|
+
position_ms: None,
|
|
2271
|
+
volume: None,
|
|
2272
|
+
error_code: None,
|
|
2273
|
+
error_message: None,
|
|
2274
|
+
metric_name: Some("decode_error".into()),
|
|
2275
|
+
metric_value_ms: None,
|
|
2276
|
+
metric_message: Some(event.message.clone()),
|
|
2277
|
+
credentials_json: None,
|
|
2278
|
+
};
|
|
2279
|
+
let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if error_sent.load(Ordering::Acquire) {
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
if !is_audio_key_error(&event) {
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
if error_sent.swap(true, Ordering::AcqRel) {
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
let payload = ConnectEvent {
|
|
2292
|
+
r#type: "error".into(),
|
|
2293
|
+
device_id: Some(device_id_for_logs.clone()),
|
|
2294
|
+
session_id: Some(session_id_for_logs.clone()),
|
|
2295
|
+
track_id: None,
|
|
2296
|
+
uri: None,
|
|
2297
|
+
title: None,
|
|
2298
|
+
artist: None,
|
|
2299
|
+
album: None,
|
|
2300
|
+
duration_ms: None,
|
|
2301
|
+
position_ms: None,
|
|
2302
|
+
volume: None,
|
|
2303
|
+
error_code: Some("audio_key_error".into()),
|
|
2304
|
+
error_message: Some(event.message.clone()),
|
|
2305
|
+
metric_name: None,
|
|
2306
|
+
metric_value_ms: None,
|
|
2307
|
+
metric_message: None,
|
|
2308
|
+
credentials_json: None,
|
|
2309
|
+
};
|
|
2310
|
+
let _ = tsfn_ev.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2210
2315
|
let (spirc, spirc_task) = Spirc::new(
|
|
2211
2316
|
connect_config,
|
|
2212
2317
|
session,
|
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. */
|