@lox-audioserver/node-librespot 0.3.2 → 0.3.4
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 +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/package.json +2 -2
- 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 +44 -0
- package/src/lib.rs +83 -5
package/Cargo.lock
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
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
5
|
setLogLevel(level: string): void;
|
|
5
6
|
loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
|
|
6
7
|
downloadTrack(opts: DownloadTrackOpts, onChunk: (chunk: Buffer) => void, onLog?: (event: LogEvent) => void): Promise<DownloadHandle>;
|
|
7
8
|
startZeroconfLogin(deviceId: string, name?: string | null, timeoutMs?: number | null): Promise<CredentialsResult>;
|
|
8
9
|
startConnectDevice(credentialsPath: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
10
|
+
startConnectDeviceWithCredentials(credentialsPath: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
9
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>;
|
|
10
12
|
};
|
|
11
13
|
export declare function createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
|
|
14
|
+
export declare function createSessionWithCredentials(credentialsPathOrJson: string, deviceName?: string | null): Promise<LibrespotSession>;
|
|
12
15
|
export declare function loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
|
|
13
16
|
export declare function startZeroconfLogin(deviceId: string, name?: string, timeoutMs?: number): Promise<CredentialsResult>;
|
|
14
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>;
|
|
18
|
+
export declare function startConnectDeviceWithCredentials(credentialsPathOrJson: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
15
19
|
export declare function startConnectDeviceWithToken(accessToken: string, clientId: string | undefined, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
16
20
|
export declare function setLogLevel(level: string): void;
|
|
17
21
|
export declare function downloadTrack(opts: DownloadTrackOpts, onChunk: (chunk: Buffer) => void, onLog?: (event: LogEvent) => void): Promise<DownloadHandle>;
|
package/dist/index.js
CHANGED
|
@@ -19,9 +19,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.native = void 0;
|
|
21
21
|
exports.createSession = createSession;
|
|
22
|
+
exports.createSessionWithCredentials = createSessionWithCredentials;
|
|
22
23
|
exports.loginWithAccessToken = loginWithAccessToken;
|
|
23
24
|
exports.startZeroconfLogin = startZeroconfLogin;
|
|
24
25
|
exports.startConnectDevice = startConnectDevice;
|
|
26
|
+
exports.startConnectDeviceWithCredentials = startConnectDeviceWithCredentials;
|
|
25
27
|
exports.startConnectDeviceWithToken = startConnectDeviceWithToken;
|
|
26
28
|
exports.setLogLevel = setLogLevel;
|
|
27
29
|
exports.downloadTrack = downloadTrack;
|
|
@@ -101,6 +103,11 @@ function createSession(opts) {
|
|
|
101
103
|
};
|
|
102
104
|
return native.createSession(nativeOpts).then((sess) => wrapSession(sess));
|
|
103
105
|
}
|
|
106
|
+
function createSessionWithCredentials(credentialsPathOrJson, deviceName) {
|
|
107
|
+
return native
|
|
108
|
+
.createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null)
|
|
109
|
+
.then((sess) => wrapSession(sess));
|
|
110
|
+
}
|
|
104
111
|
function loginWithAccessToken(accessToken, deviceName) {
|
|
105
112
|
return native.loginWithAccessToken(accessToken, deviceName).then((res) => {
|
|
106
113
|
const credentialsJson = res.credentialsJson ?? res.credentials_json;
|
|
@@ -125,6 +132,19 @@ function startConnectDevice(credentialsPath, name, deviceId, onChunk, onEvent, o
|
|
|
125
132
|
// Legacy entrypoint kept for API compatibility; immediately fails.
|
|
126
133
|
return Promise.reject(new Error('startConnectDevice is deprecated; use startConnectDeviceWithToken(accessToken, clientId, ...)'));
|
|
127
134
|
}
|
|
135
|
+
function startConnectDeviceWithCredentials(credentialsPathOrJson, name, deviceId, onChunk, onEvent, onLog) {
|
|
136
|
+
return Promise.resolve(native.startConnectDeviceWithCredentials(credentialsPathOrJson, name, deviceId, onChunk, onEvent, onLog)).then((handle) => ({
|
|
137
|
+
stop: () => handle.stop(),
|
|
138
|
+
shutdown: () => handle.shutdown(),
|
|
139
|
+
close: () => handle.close(),
|
|
140
|
+
play: () => handle.play(),
|
|
141
|
+
pause: () => handle.pause(),
|
|
142
|
+
next: () => handle.next(),
|
|
143
|
+
prev: () => handle.prev(),
|
|
144
|
+
sampleRate: handle.sampleRate ?? handle.sample_rate ?? handle.sampleRate,
|
|
145
|
+
channels: handle.channels,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
128
148
|
function startConnectDeviceWithToken(accessToken, clientId, name, deviceId, onChunk, onEvent, onLog) {
|
|
129
149
|
return Promise.resolve(native.startConnectDeviceWithToken(accessToken, clientId, name, deviceId, onChunk, onEvent, onLog)).then((handle) => ({
|
|
130
150
|
stop: () => handle.stop(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lox-audioserver/node-librespot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
],
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
|
42
|
-
"url": "https://github.com/lox-audioserver/node-librespot"
|
|
42
|
+
"url": "git+https://github.com/lox-audioserver/node-librespot.git"
|
|
43
43
|
},
|
|
44
44
|
"author": "Rudy Berends",
|
|
45
45
|
"license": "Apache-2.0",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,10 @@ function resolveNativeBinding() {
|
|
|
67
67
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
68
68
|
const native = require(resolveNativeBinding()) as {
|
|
69
69
|
createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
|
|
70
|
+
createSessionWithCredentials(
|
|
71
|
+
credentialsPath: string,
|
|
72
|
+
deviceName?: string | null,
|
|
73
|
+
): Promise<LibrespotSession>;
|
|
70
74
|
setLogLevel(level: string): void;
|
|
71
75
|
loginWithAccessToken(
|
|
72
76
|
accessToken: string,
|
|
@@ -90,6 +94,14 @@ const native = require(resolveNativeBinding()) as {
|
|
|
90
94
|
onEvent?: (event: ConnectEvent) => void,
|
|
91
95
|
onLog?: (event: LogEvent) => void,
|
|
92
96
|
): Promise<ConnectHandle>;
|
|
97
|
+
startConnectDeviceWithCredentials(
|
|
98
|
+
credentialsPath: string,
|
|
99
|
+
name: string,
|
|
100
|
+
deviceId: string,
|
|
101
|
+
onChunk: (chunk: Buffer) => void,
|
|
102
|
+
onEvent?: (event: ConnectEvent) => void,
|
|
103
|
+
onLog?: (event: LogEvent) => void,
|
|
104
|
+
): Promise<ConnectHandle>;
|
|
93
105
|
startConnectDeviceWithToken(
|
|
94
106
|
accessToken: string,
|
|
95
107
|
clientId: string | undefined,
|
|
@@ -144,6 +156,15 @@ export function createSession(opts: CreateSessionOpts): Promise<LibrespotSession
|
|
|
144
156
|
return native.createSession(nativeOpts as CreateSessionOpts).then((sess) => wrapSession(sess) as any);
|
|
145
157
|
}
|
|
146
158
|
|
|
159
|
+
export function createSessionWithCredentials(
|
|
160
|
+
credentialsPathOrJson: string,
|
|
161
|
+
deviceName?: string | null,
|
|
162
|
+
): Promise<LibrespotSession> {
|
|
163
|
+
return native
|
|
164
|
+
.createSessionWithCredentials(credentialsPathOrJson, deviceName ?? null)
|
|
165
|
+
.then((sess) => wrapSession(sess) as any);
|
|
166
|
+
}
|
|
167
|
+
|
|
147
168
|
export function loginWithAccessToken(
|
|
148
169
|
accessToken: string,
|
|
149
170
|
deviceName?: string,
|
|
@@ -187,6 +208,29 @@ export function startConnectDevice(
|
|
|
187
208
|
);
|
|
188
209
|
}
|
|
189
210
|
|
|
211
|
+
export function startConnectDeviceWithCredentials(
|
|
212
|
+
credentialsPathOrJson: string,
|
|
213
|
+
name: string,
|
|
214
|
+
deviceId: string,
|
|
215
|
+
onChunk: (chunk: Buffer) => void,
|
|
216
|
+
onEvent?: (event: ConnectEvent) => void,
|
|
217
|
+
onLog?: (event: LogEvent) => void,
|
|
218
|
+
): Promise<ConnectHandle> {
|
|
219
|
+
return Promise.resolve(
|
|
220
|
+
native.startConnectDeviceWithCredentials(credentialsPathOrJson, name, deviceId, onChunk, onEvent, onLog),
|
|
221
|
+
).then((handle: ConnectHandle & { sample_rate?: number }) => ({
|
|
222
|
+
stop: () => handle.stop(),
|
|
223
|
+
shutdown: () => handle.shutdown(),
|
|
224
|
+
close: () => handle.close(),
|
|
225
|
+
play: () => handle.play(),
|
|
226
|
+
pause: () => handle.pause(),
|
|
227
|
+
next: () => handle.next(),
|
|
228
|
+
prev: () => handle.prev(),
|
|
229
|
+
sampleRate: (handle as any).sampleRate ?? (handle as any).sample_rate ?? (handle as any).sampleRate,
|
|
230
|
+
channels: (handle as any).channels,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
190
234
|
export function startConnectDeviceWithToken(
|
|
191
235
|
accessToken: string,
|
|
192
236
|
clientId: string | undefined,
|
package/src/lib.rs
CHANGED
|
@@ -550,6 +550,7 @@ impl Sink for ChannelSink {
|
|
|
550
550
|
self.last_pcm_at.store(now_ms, Ordering::Release);
|
|
551
551
|
}
|
|
552
552
|
// Pacing: throttle to approximate realtime based on sample count.
|
|
553
|
+
// Send first chunk immediately to minimize startup latency; apply pacing after forwarding.
|
|
553
554
|
let bytes_per_sample = match self.format {
|
|
554
555
|
AudioFormat::S24 => 3,
|
|
555
556
|
AudioFormat::S32 | AudioFormat::F32 => 4,
|
|
@@ -557,19 +558,17 @@ impl Sink for ChannelSink {
|
|
|
557
558
|
};
|
|
558
559
|
let samples = bytes.len() / (bytes_per_sample * self.channels as usize);
|
|
559
560
|
let duration = Duration::from_secs_f64(samples as f64 / self.sample_rate as f64);
|
|
561
|
+
if self.tx.try_send(bytes).is_err() {
|
|
562
|
+
// Drop chunk if JS side is backpressured to avoid blocking the player thread.
|
|
563
|
+
}
|
|
560
564
|
let start = self.start.get_or_insert_with(Instant::now);
|
|
561
565
|
self.expected_elapsed += duration;
|
|
562
566
|
let target = *start + self.expected_elapsed;
|
|
563
567
|
let now = Instant::now();
|
|
564
568
|
let sleep_dur = target.saturating_duration_since(now);
|
|
565
|
-
|
|
566
569
|
if !sleep_dur.is_zero() {
|
|
567
570
|
sleep(sleep_dur);
|
|
568
571
|
}
|
|
569
|
-
|
|
570
|
-
if self.tx.try_send(bytes).is_err() {
|
|
571
|
-
// Drop chunk if JS side is backpressured to avoid blocking the player thread.
|
|
572
|
-
}
|
|
573
572
|
Ok(())
|
|
574
573
|
}
|
|
575
574
|
}
|
|
@@ -1505,6 +1504,58 @@ pub async fn create_session(opts: CreateSessionOpts) -> Result<LibrespotSession>
|
|
|
1505
1504
|
})
|
|
1506
1505
|
}
|
|
1507
1506
|
|
|
1507
|
+
/// Create a session using a reusable librespot credentials blob (JSON) or a path to credentials.json.
|
|
1508
|
+
///
|
|
1509
|
+
/// This avoids relying on a Web API access token for streaming.
|
|
1510
|
+
#[napi]
|
|
1511
|
+
pub async fn create_session_with_credentials(
|
|
1512
|
+
credentials_path: String,
|
|
1513
|
+
device_name: Option<String>,
|
|
1514
|
+
) -> Result<LibrespotSession> {
|
|
1515
|
+
if credentials_path.trim().is_empty() {
|
|
1516
|
+
return Err(Error::from_reason("credentials payload is required"));
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
let credentials: Credentials = if Path::new(&credentials_path).exists() {
|
|
1520
|
+
let mut file =
|
|
1521
|
+
File::open(&credentials_path).map_err(|e| Error::from_reason(format!("{e}")))?;
|
|
1522
|
+
let mut buf = String::new();
|
|
1523
|
+
file.read_to_string(&mut buf)
|
|
1524
|
+
.map_err(|e| Error::from_reason(format!("{e}")))?;
|
|
1525
|
+
serde_json::from_str(&buf)
|
|
1526
|
+
.map_err(|e| Error::from_reason(format!("invalid credentials json: {e}")))?
|
|
1527
|
+
} else {
|
|
1528
|
+
serde_json::from_str(&credentials_path)
|
|
1529
|
+
.map_err(|e| Error::from_reason(format!("invalid credentials json: {e}")))?
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
let mut session_config = SessionConfig::default();
|
|
1533
|
+
let mut device_id = device_name.unwrap_or_else(|| "librespot".to_string());
|
|
1534
|
+
if device_id.trim().is_empty() {
|
|
1535
|
+
device_id = "librespot".to_string();
|
|
1536
|
+
}
|
|
1537
|
+
session_config.device_id = device_id.clone();
|
|
1538
|
+
if let Ok(client_id_override) = std::env::var("LOX_LIBRESPOT_CLIENT_ID") {
|
|
1539
|
+
if !client_id_override.trim().is_empty() {
|
|
1540
|
+
session_config.client_id = client_id_override;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
let session = Session::new(session_config, None);
|
|
1545
|
+
session
|
|
1546
|
+
.connect(credentials, false)
|
|
1547
|
+
.await
|
|
1548
|
+
.map_err(|e| Error::from_reason(format!("session connect failed: {e}")))?;
|
|
1549
|
+
|
|
1550
|
+
let player_config = PlayerConfig::default();
|
|
1551
|
+
|
|
1552
|
+
Ok(LibrespotSession {
|
|
1553
|
+
session,
|
|
1554
|
+
player_config,
|
|
1555
|
+
device_id,
|
|
1556
|
+
})
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1508
1559
|
/// Internal helper to start a Spotify Connect device using provided credentials.
|
|
1509
1560
|
/// Accepts credentials (typically created from an OAuth access token) and is shared by the
|
|
1510
1561
|
/// token-based public entrypoint.
|
|
@@ -1646,6 +1697,7 @@ fn start_connect_device_inner(
|
|
|
1646
1697
|
name: name.clone(),
|
|
1647
1698
|
device_type: DeviceType::Speaker,
|
|
1648
1699
|
is_group: false,
|
|
1700
|
+
emit_set_queue_events: false,
|
|
1649
1701
|
// Start with full volume so we rely on zone-side volume control; we do not sync Spotify volume.
|
|
1650
1702
|
// Spotify volume scale is 0..65535; use max to avoid muted start.
|
|
1651
1703
|
initial_volume: u16::MAX,
|
|
@@ -2130,6 +2182,32 @@ pub fn start_connect_device(
|
|
|
2130
2182
|
))
|
|
2131
2183
|
}
|
|
2132
2184
|
|
|
2185
|
+
/// Start a Spotify Connect device using an existing credentials JSON blob (or a path to credentials.json).
|
|
2186
|
+
///
|
|
2187
|
+
/// This avoids exchanging a Web API token for credentials, and allows reusing credentials minted via
|
|
2188
|
+
/// Zeroconf or other flows.
|
|
2189
|
+
#[napi]
|
|
2190
|
+
pub fn start_connect_device_with_credentials(
|
|
2191
|
+
credentials_path: String,
|
|
2192
|
+
name: String,
|
|
2193
|
+
device_id: String,
|
|
2194
|
+
on_chunk: JsFunction,
|
|
2195
|
+
on_event: Option<JsFunction>,
|
|
2196
|
+
on_log: Option<JsFunction>,
|
|
2197
|
+
) -> Result<ConnectHandle> {
|
|
2198
|
+
if credentials_path.trim().is_empty() {
|
|
2199
|
+
return Err(Error::from_reason("credentials payload is required"));
|
|
2200
|
+
}
|
|
2201
|
+
start_connect_device_inner(
|
|
2202
|
+
credentials_path,
|
|
2203
|
+
name,
|
|
2204
|
+
device_id,
|
|
2205
|
+
on_chunk,
|
|
2206
|
+
on_event,
|
|
2207
|
+
on_log,
|
|
2208
|
+
)
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2133
2211
|
/// Start a Spotify Connect device using a Web API access token + client id (bypasses builtin login).
|
|
2134
2212
|
#[napi]
|
|
2135
2213
|
pub fn start_connect_device_with_token(
|