@lox-audioserver/node-librespot 0.3.2
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 +3597 -0
- package/Cargo.toml +32 -0
- package/README.md +105 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +151 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +3 -0
- package/package.json +64 -0
- 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/scripts/postinstall.js +59 -0
- package/scripts/prebuild.js +23 -0
- package/scripts/prepare-prebuilds.js +44 -0
- package/scripts/test-connect.js +113 -0
- package/scripts/test-service.js +205 -0
- package/scripts/test.js +112 -0
- package/scripts/update-librespot.sh +13 -0
- package/src/index.ts +232 -0
- package/src/lib.rs +2305 -0
- package/src/types.ts +129 -0
package/Cargo.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "librespot-addon"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Rust-based librespot binding for Node via N-API."
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
napi = { version = "2", features = ["tokio_rt"] }
|
|
13
|
+
napi-derive = "2"
|
|
14
|
+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
|
15
|
+
futures = "0.3"
|
|
16
|
+
bytes = "1"
|
|
17
|
+
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
|
|
18
|
+
librespot-core = { path = "./librespot-dev/core", default-features = false, features = [
|
|
19
|
+
"rustls-tls-native-roots",
|
|
20
|
+
] }
|
|
21
|
+
librespot-audio = { path = "./librespot-dev/audio", default-features = false, features = [
|
|
22
|
+
"rustls-tls-native-roots",
|
|
23
|
+
] }
|
|
24
|
+
librespot-playback = { path = "./librespot-dev/playback", default-features = false }
|
|
25
|
+
librespot-discovery = { path = "./librespot-dev/discovery", default-features = false, features = [
|
|
26
|
+
"with-libmdns",
|
|
27
|
+
] }
|
|
28
|
+
librespot-connect = { path = "./librespot-dev/connect", default-features = false }
|
|
29
|
+
librespot-metadata = { path = "./librespot-dev/metadata", default-features = false }
|
|
30
|
+
serde_json = "1"
|
|
31
|
+
tokio-stream = "0.1"
|
|
32
|
+
log = { version = "0.4", features = ["std"] }
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# node-librespot
|
|
2
|
+
|
|
3
|
+
Native Node.js bindings for [librespot](https://github.com/librespot-org/librespot) (Spotify Connect) using N-API. Streams PCM directly to JavaScript without spawning the librespot binary.
|
|
4
|
+
|
|
5
|
+
Used by [lox-audioserver](https://github.com/rudyberends/lox-audioserver) to handle spotify traffic, but can be used by other software.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
- Stream a Spotify track/episode to PCM buffers (`streamTrack`) using a Web API **access token**.
|
|
9
|
+
- Host a Spotify Connect endpoint with a Web API access token + your own Spotify app client id (`startConnectDeviceWithToken`).
|
|
10
|
+
- No disk cache required; everything stays in-memory.
|
|
11
|
+
- Starts Connect hosts at max volume (volume control stays on the consumer side).
|
|
12
|
+
- `startConnectDevice` (credential-blob based) is deprecated and will error.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Build
|
|
20
|
+
Requires Rust (stable) and `@napi-rs/cli` (installed via devDependencies).
|
|
21
|
+
```bash
|
|
22
|
+
npm run build # release build
|
|
23
|
+
# or
|
|
24
|
+
npm run build:debug # debug build
|
|
25
|
+
# or (for publishing) generate host prebuild + JS/types
|
|
26
|
+
npm run build:prebuild
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Runtime will load `librespot_addon.node` from `prebuilds/<platform-arch>/` when available, otherwise it falls back to `LOX_LIBRESPOT_ADDON_PATH` or the locally compiled `dist/librespot_addon.node`.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
```ts
|
|
33
|
+
import { createSession, setLogLevel } from 'node-librespot';
|
|
34
|
+
|
|
35
|
+
setLogLevel('debug'); // off|error|warn|info|debug|trace
|
|
36
|
+
|
|
37
|
+
const session = await createSession({
|
|
38
|
+
accessToken, // user Web API access token (PKCE/Authorization Code)
|
|
39
|
+
clientId, // your Spotify app client id
|
|
40
|
+
deviceName: 'lox-zone-1',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const handle = session.streamTrack(
|
|
44
|
+
{ uri: 'spotify:track:...', bitrate: 320 },
|
|
45
|
+
(chunk) => {
|
|
46
|
+
// chunk is a Buffer with PCM s16le frames (44.1kHz stereo)
|
|
47
|
+
},
|
|
48
|
+
(event) => {
|
|
49
|
+
// optional events: playing/paused/loading/stopped/end_of_track/error/health/metric
|
|
50
|
+
// error codes include: audio_key_error, no_pcm, end_of_track, pcm_missing, pcm_stalled, unavailable
|
|
51
|
+
// metric names include: first_pcm_ms, buffer_stall_ms, decode_error
|
|
52
|
+
},
|
|
53
|
+
(log) => {
|
|
54
|
+
// optional logging callback { level, message, scope }
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Host a Connect device with the same token
|
|
59
|
+
import { startConnectDeviceWithToken } from 'node-librespot';
|
|
60
|
+
|
|
61
|
+
const host = await startConnectDeviceWithToken(
|
|
62
|
+
accessToken,
|
|
63
|
+
clientId,
|
|
64
|
+
'Lox-AudioServer', // publish name
|
|
65
|
+
'lox-zone-1', // device id
|
|
66
|
+
(chunk) => { /* PCM s16le frames */ },
|
|
67
|
+
(event) => { /* playing/paused/loading/end_of_track/error/health/metric with metadata */ },
|
|
68
|
+
(log) => { /* optional log callback */ },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
host.play();
|
|
72
|
+
host.pause();
|
|
73
|
+
host.next();
|
|
74
|
+
host.prev();
|
|
75
|
+
host.stop();
|
|
76
|
+
host.shutdown(); // alias for stop()
|
|
77
|
+
host.close(); // alias for stop()
|
|
78
|
+
|
|
79
|
+
// Or pull decrypted audio file bytes (Ogg/MP3) without decoding:
|
|
80
|
+
const download = await downloadTrack(
|
|
81
|
+
{ uri: 'spotify:track:...', bitrate: 320 },
|
|
82
|
+
(chunk) => {
|
|
83
|
+
// chunk is a Buffer with decrypted compressed audio data
|
|
84
|
+
},
|
|
85
|
+
(log) => {
|
|
86
|
+
// optional logging callback { level, message, scope }
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
// download.stop() to cancel
|
|
90
|
+
// Promise rejects on initial errors (invalid URI, unavailable track, key/file fetch failure).
|
|
91
|
+
|
|
92
|
+
handle.stop();
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Authentication
|
|
96
|
+
- Only Web API access tokens are supported (bring your own PKCE/Authorization Code flow).
|
|
97
|
+
- Supply your app’s client id via `clientId` or `LOX_LIBRESPOT_CLIENT_ID` env var.
|
|
98
|
+
- `startConnectDevice` is removed; use `startConnectDeviceWithToken` instead.
|
|
99
|
+
|
|
100
|
+
If the token is invalid/expired, playback/Connect hosting will fail; refresh the token in your host app and retry.
|
|
101
|
+
|
|
102
|
+
## Development / publish
|
|
103
|
+
- CI: `.github/workflows/prebuild.yml` runs on release creation, building prebuild artifacts for Linux x64 (glibc), Linux arm64 (glibc), and macOS arm64 before publishing to npm (requires `NPM_TOKEN` secret).
|
|
104
|
+
- Local publish flow: `npm run build:prebuild` to generate host prebuild + JS/types, then `npm publish`.
|
|
105
|
+
- Install from source: `npm install` will try to use an included prebuild; if none matches the host it will build from source automatically (set `LOX_LIBRESPOT_SKIP_BUILD=1` to skip).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConnectHandle, ConnectEvent, CreateSessionOpts, CredentialsResult, LibrespotSession, LogEvent, DownloadTrackOpts, DownloadHandle } from './types';
|
|
2
|
+
declare const native: {
|
|
3
|
+
createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
|
|
4
|
+
setLogLevel(level: string): void;
|
|
5
|
+
loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
|
|
6
|
+
downloadTrack(opts: DownloadTrackOpts, onChunk: (chunk: Buffer) => void, onLog?: (event: LogEvent) => void): Promise<DownloadHandle>;
|
|
7
|
+
startZeroconfLogin(deviceId: string, name?: string | null, timeoutMs?: number | null): Promise<CredentialsResult>;
|
|
8
|
+
startConnectDevice(credentialsPath: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
9
|
+
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
|
+
};
|
|
11
|
+
export declare function createSession(opts: CreateSessionOpts): Promise<LibrespotSession>;
|
|
12
|
+
export declare function loginWithAccessToken(accessToken: string, deviceName?: string): Promise<CredentialsResult>;
|
|
13
|
+
export declare function startZeroconfLogin(deviceId: string, name?: string, timeoutMs?: number): Promise<CredentialsResult>;
|
|
14
|
+
export declare function startConnectDevice(credentialsPath: string, name: string, deviceId: string, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): Promise<ConnectHandle>;
|
|
15
|
+
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
|
+
export declare function setLogLevel(level: string): void;
|
|
17
|
+
export declare function downloadTrack(opts: DownloadTrackOpts, onChunk: (chunk: Buffer) => void, onLog?: (event: LogEvent) => void): Promise<DownloadHandle>;
|
|
18
|
+
export { native };
|
|
19
|
+
export * from './types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.native = void 0;
|
|
21
|
+
exports.createSession = createSession;
|
|
22
|
+
exports.loginWithAccessToken = loginWithAccessToken;
|
|
23
|
+
exports.startZeroconfLogin = startZeroconfLogin;
|
|
24
|
+
exports.startConnectDevice = startConnectDevice;
|
|
25
|
+
exports.startConnectDeviceWithToken = startConnectDeviceWithToken;
|
|
26
|
+
exports.setLogLevel = setLogLevel;
|
|
27
|
+
exports.downloadTrack = downloadTrack;
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
function detectLibc() {
|
|
31
|
+
const glibcVersionRuntime =
|
|
32
|
+
// @ts-expect-error
|
|
33
|
+
process.report?.getReport?.()?.header?.glibcVersionRuntime;
|
|
34
|
+
return glibcVersionRuntime ? 'gnu' : 'musl';
|
|
35
|
+
}
|
|
36
|
+
function platformArchABI() {
|
|
37
|
+
const { platform, arch } = process;
|
|
38
|
+
if (platform === 'linux') {
|
|
39
|
+
return `linux-${arch}-${detectLibc()}`;
|
|
40
|
+
}
|
|
41
|
+
if (platform === 'darwin') {
|
|
42
|
+
return `darwin-${arch}`;
|
|
43
|
+
}
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
return `win32-${arch}-msvc`;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Unsupported platform ${platform}-${arch}`);
|
|
48
|
+
}
|
|
49
|
+
function resolveNativeBinding() {
|
|
50
|
+
const override = process.env.LOX_LIBRESPOT_ADDON_PATH;
|
|
51
|
+
if (override && node_fs_1.default.existsSync(override)) {
|
|
52
|
+
return override;
|
|
53
|
+
}
|
|
54
|
+
const prebuiltPath = node_path_1.default.join(__dirname, '..', 'prebuilds', platformArchABI(), 'librespot_addon.node');
|
|
55
|
+
if (node_fs_1.default.existsSync(prebuiltPath)) {
|
|
56
|
+
return prebuiltPath;
|
|
57
|
+
}
|
|
58
|
+
const localBuildPath = node_path_1.default.join(__dirname, 'librespot_addon.node');
|
|
59
|
+
if (node_fs_1.default.existsSync(localBuildPath)) {
|
|
60
|
+
return localBuildPath;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`librespot_addon.node not found for ${platformArchABI()}. ` +
|
|
63
|
+
'Install a prebuilt binary, build locally with "npm run build", ' +
|
|
64
|
+
'or point LOX_LIBRESPOT_ADDON_PATH to the compiled addon.');
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
67
|
+
const native = require(resolveNativeBinding());
|
|
68
|
+
exports.native = native;
|
|
69
|
+
function wrapStreamHandle(handle) {
|
|
70
|
+
return {
|
|
71
|
+
stop: () => handle.stop(),
|
|
72
|
+
get sampleRate() {
|
|
73
|
+
return handle.sampleRate ?? handle.sample_rate ?? handle.sampleRate;
|
|
74
|
+
},
|
|
75
|
+
get channels() {
|
|
76
|
+
return handle.channels ?? handle.channels;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function wrapSession(session) {
|
|
81
|
+
return {
|
|
82
|
+
streamTrack: (opts, onChunk, onEvent, onLog) => {
|
|
83
|
+
const nativeOpts = {
|
|
84
|
+
uri: opts.uri,
|
|
85
|
+
startPositionMs: opts.startPositionMs ?? opts.start_position_ms,
|
|
86
|
+
bitrate: opts.bitrate,
|
|
87
|
+
output: opts.output,
|
|
88
|
+
emitEvents: opts.emitEvents ?? opts.emit_events,
|
|
89
|
+
};
|
|
90
|
+
const handle = session.streamTrack(nativeOpts, onChunk, onEvent, onLog);
|
|
91
|
+
return wrapStreamHandle(handle);
|
|
92
|
+
},
|
|
93
|
+
close: () => session.close(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function createSession(opts) {
|
|
97
|
+
const nativeOpts = {
|
|
98
|
+
accessToken: opts.accessToken ?? opts.access_token,
|
|
99
|
+
clientId: opts.clientId ?? opts.client_id,
|
|
100
|
+
deviceName: opts.deviceName ?? opts.device_name,
|
|
101
|
+
};
|
|
102
|
+
return native.createSession(nativeOpts).then((sess) => wrapSession(sess));
|
|
103
|
+
}
|
|
104
|
+
function loginWithAccessToken(accessToken, deviceName) {
|
|
105
|
+
return native.loginWithAccessToken(accessToken, deviceName).then((res) => {
|
|
106
|
+
const credentialsJson = res.credentialsJson ?? res.credentials_json;
|
|
107
|
+
return {
|
|
108
|
+
...res,
|
|
109
|
+
credentialsJson,
|
|
110
|
+
credentials_json: credentialsJson ?? res.credentials_json,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function startZeroconfLogin(deviceId, name, timeoutMs) {
|
|
115
|
+
return native.startZeroconfLogin(deviceId, name, timeoutMs).then((res) => {
|
|
116
|
+
const credentialsJson = res.credentialsJson ?? res.credentials_json;
|
|
117
|
+
return {
|
|
118
|
+
...res,
|
|
119
|
+
credentialsJson,
|
|
120
|
+
credentials_json: credentialsJson ?? res.credentials_json,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function startConnectDevice(credentialsPath, name, deviceId, onChunk, onEvent, onLog) {
|
|
125
|
+
// Legacy entrypoint kept for API compatibility; immediately fails.
|
|
126
|
+
return Promise.reject(new Error('startConnectDevice is deprecated; use startConnectDeviceWithToken(accessToken, clientId, ...)'));
|
|
127
|
+
}
|
|
128
|
+
function startConnectDeviceWithToken(accessToken, clientId, name, deviceId, onChunk, onEvent, onLog) {
|
|
129
|
+
return Promise.resolve(native.startConnectDeviceWithToken(accessToken, clientId, name, deviceId, onChunk, onEvent, onLog)).then((handle) => ({
|
|
130
|
+
stop: () => handle.stop(),
|
|
131
|
+
shutdown: () => handle.shutdown(),
|
|
132
|
+
close: () => handle.close(),
|
|
133
|
+
play: () => handle.play(),
|
|
134
|
+
pause: () => handle.pause(),
|
|
135
|
+
next: () => handle.next(),
|
|
136
|
+
prev: () => handle.prev(),
|
|
137
|
+
sampleRate: handle.sampleRate ?? handle.sample_rate ?? handle.sampleRate,
|
|
138
|
+
channels: handle.channels,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
function setLogLevel(level) {
|
|
142
|
+
native.setLogLevel(level);
|
|
143
|
+
}
|
|
144
|
+
function downloadTrack(opts, onChunk, onLog) {
|
|
145
|
+
const nativeOpts = {
|
|
146
|
+
uri: opts.uri,
|
|
147
|
+
bitrate: opts.bitrate,
|
|
148
|
+
};
|
|
149
|
+
return native.downloadTrack(nativeOpts, onChunk, onLog);
|
|
150
|
+
}
|
|
151
|
+
__exportStar(require("./types"), exports);
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Options used to create a librespot session. */
|
|
2
|
+
export interface CreateSessionOpts {
|
|
3
|
+
accessToken?: string;
|
|
4
|
+
clientId?: string;
|
|
5
|
+
deviceName?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Options for streaming a track. */
|
|
8
|
+
export interface StreamTrackOpts {
|
|
9
|
+
uri: string;
|
|
10
|
+
startPositionMs?: number;
|
|
11
|
+
bitrate?: number;
|
|
12
|
+
output?: string;
|
|
13
|
+
emitEvents?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Options for downloading raw (decrypted) audio bytes. */
|
|
16
|
+
export interface DownloadTrackOpts {
|
|
17
|
+
uri: string;
|
|
18
|
+
bitrate?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Result of a credentials login flow. */
|
|
21
|
+
export interface CredentialsResult {
|
|
22
|
+
username: string;
|
|
23
|
+
credentialsJson: string;
|
|
24
|
+
credentials_json?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Helper signature for OAuth-based credentials minting. */
|
|
27
|
+
export type LoginWithAccessToken = (accessToken: string, deviceName?: string) => Promise<CredentialsResult>;
|
|
28
|
+
export type LibrespotEventType = 'playing' | 'paused' | 'loading' | 'stopped' | 'end_of_track' | 'unavailable' | 'volume' | 'position_correction' | 'health' | 'error' | 'metric' | 'preloading' | 'time_to_preload' | 'play_request_id' | 'other';
|
|
29
|
+
export type LibrespotErrorCode = 'audio_key_error' | 'no_pcm' | 'end_of_track' | 'pcm_missing' | 'pcm_stalled' | 'pcm_ok' | 'unavailable' | 'unknown';
|
|
30
|
+
/** Event payload emitted by the connect host. */
|
|
31
|
+
export interface ConnectEvent {
|
|
32
|
+
type: LibrespotEventType;
|
|
33
|
+
deviceId?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
trackId?: string;
|
|
36
|
+
uri?: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
artist?: string;
|
|
39
|
+
album?: string;
|
|
40
|
+
durationMs?: number;
|
|
41
|
+
positionMs?: number;
|
|
42
|
+
volume?: number;
|
|
43
|
+
errorCode?: LibrespotErrorCode;
|
|
44
|
+
errorMessage?: string;
|
|
45
|
+
metricName?: string;
|
|
46
|
+
metricValueMs?: number;
|
|
47
|
+
metricMessage?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Log payload emitted by the native module. */
|
|
50
|
+
export interface LogEvent {
|
|
51
|
+
level: string;
|
|
52
|
+
message: string;
|
|
53
|
+
scope?: string;
|
|
54
|
+
deviceId?: string;
|
|
55
|
+
sessionId?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Handle returned when hosting a connect device. */
|
|
58
|
+
export interface ConnectHandle {
|
|
59
|
+
stop(): void;
|
|
60
|
+
shutdown(): void;
|
|
61
|
+
close(): void;
|
|
62
|
+
play(): void;
|
|
63
|
+
pause(): void;
|
|
64
|
+
next(): void;
|
|
65
|
+
prev(): void;
|
|
66
|
+
sampleRate: number;
|
|
67
|
+
channels: number;
|
|
68
|
+
}
|
|
69
|
+
/** Handle to a librespot session. */
|
|
70
|
+
export interface LibrespotSession {
|
|
71
|
+
streamTrack(opts: StreamTrackOpts, onChunk: (chunk: Buffer) => void, onEvent?: (event: ConnectEvent) => void, onLog?: (event: LogEvent) => void): StreamHandle;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export interface StreamHandle {
|
|
75
|
+
stop(): void;
|
|
76
|
+
sampleRate: number;
|
|
77
|
+
channels: number;
|
|
78
|
+
}
|
|
79
|
+
/** Handle for a raw download stream. */
|
|
80
|
+
export interface DownloadHandle {
|
|
81
|
+
stop(): void;
|
|
82
|
+
}
|
|
83
|
+
export declare function setLogLevel(level: string): void;
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lox-audioserver/node-librespot",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Node.js bindings for librespot (Spotify Connect) via N-API with prebuild support.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/**/*",
|
|
9
|
+
"prebuilds/**/*",
|
|
10
|
+
"index.d.ts",
|
|
11
|
+
"scripts/**/*",
|
|
12
|
+
"src/**/*",
|
|
13
|
+
"native/**/*",
|
|
14
|
+
"Cargo.toml",
|
|
15
|
+
"Cargo.lock",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npm run clean && npm run build:native && npm run build:types",
|
|
20
|
+
"build:debug": "npm run clean && napi build && mkdir -p dist && mv -f librespot_addon.node dist/ && npm run build:types",
|
|
21
|
+
"build:native": "napi build --release --strip && mkdir -p dist && mv -f librespot_addon.node dist/",
|
|
22
|
+
"build:prebuild": "node scripts/prebuild.js",
|
|
23
|
+
"build:types": "tsc -p tsconfig.build.json && rm -f index.d.ts",
|
|
24
|
+
"build:types:clean": "rm -rf dist && npm run build:types",
|
|
25
|
+
"clean": "rm -rf build dist prebuilds *.log *.node target index.d.ts",
|
|
26
|
+
"test": "node scripts/test.js",
|
|
27
|
+
"test:connect": "node scripts/test-connect.js",
|
|
28
|
+
"test:service": "node scripts/test-service.js",
|
|
29
|
+
"update-librespot": "bash scripts/update-librespot.sh",
|
|
30
|
+
"prepublishOnly": "npm run build:types",
|
|
31
|
+
"postinstall": "node scripts/postinstall.js"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"spotify",
|
|
35
|
+
"connect",
|
|
36
|
+
"librespot",
|
|
37
|
+
"napi",
|
|
38
|
+
"audio"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/lox-audioserver/node-librespot"
|
|
43
|
+
},
|
|
44
|
+
"author": "Rudy Berends",
|
|
45
|
+
"license": "Apache-2.0",
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@napi-rs/cli": "^2.18.4",
|
|
51
|
+
"@tsconfig/node20": "^20.1.2",
|
|
52
|
+
"@types/node": "^22.19.3",
|
|
53
|
+
"typescript": "^5.4.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"node-addon-api": "^8.2.1"
|
|
57
|
+
},
|
|
58
|
+
"napi": {
|
|
59
|
+
"name": "librespot_addon",
|
|
60
|
+
"targets": [
|
|
61
|
+
"node"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
6
|
+
const ADDON_NAME = 'librespot_addon.node';
|
|
7
|
+
|
|
8
|
+
function detectLibc() {
|
|
9
|
+
const glibcVersionRuntime =
|
|
10
|
+
// @ts-expect-error
|
|
11
|
+
process.report?.getReport?.()?.header?.glibcVersionRuntime;
|
|
12
|
+
return glibcVersionRuntime ? 'gnu' : 'musl';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function platformArchABI() {
|
|
16
|
+
const { platform, arch } = process;
|
|
17
|
+
if (platform === 'linux') return `linux-${arch}-${detectLibc()}`;
|
|
18
|
+
if (platform === 'darwin') return `darwin-${arch}`;
|
|
19
|
+
if (platform === 'win32') return `win32-${arch}-msvc`;
|
|
20
|
+
return `${platform}-${arch}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveAddonPaths() {
|
|
24
|
+
const prebuildPath = path.join(ROOT, 'prebuilds', platformArchABI(), ADDON_NAME);
|
|
25
|
+
const distPath = path.join(ROOT, 'dist', ADDON_NAME);
|
|
26
|
+
return { prebuildPath, distPath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addonExists() {
|
|
30
|
+
const { prebuildPath, distPath } = resolveAddonPaths();
|
|
31
|
+
return fs.existsSync(prebuildPath) || fs.existsSync(distPath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildNative() {
|
|
35
|
+
const res = spawnSync('npm', ['run', 'build'], {
|
|
36
|
+
stdio: 'inherit',
|
|
37
|
+
cwd: ROOT,
|
|
38
|
+
env: process.env,
|
|
39
|
+
});
|
|
40
|
+
if (res.status !== 0) {
|
|
41
|
+
throw new Error(`npm run build failed with status ${res.status ?? 'unknown'}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main() {
|
|
46
|
+
if (process.env.LOX_LIBRESPOT_SKIP_BUILD) {
|
|
47
|
+
console.warn('node-librespot: skipping native build because LOX_LIBRESPOT_SKIP_BUILD is set.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (addonExists()) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.info('node-librespot: no prebuild found for this platform, building from source...');
|
|
56
|
+
buildNative();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { execSync } = require('node:child_process');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { preparePrebuilds } = require('./prepare-prebuilds');
|
|
4
|
+
|
|
5
|
+
const run = (cmd, env = {}) =>
|
|
6
|
+
execSync(cmd, {
|
|
7
|
+
stdio: 'inherit',
|
|
8
|
+
env: { ...process.env, ...env },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
run('npm run clean');
|
|
12
|
+
|
|
13
|
+
const args = ['napi', 'build', '--release', '--platform', '--strip'];
|
|
14
|
+
if (process.env.USE_ZIG === '1') {
|
|
15
|
+
args.push('--zig');
|
|
16
|
+
}
|
|
17
|
+
if (process.env.RUST_TARGET) {
|
|
18
|
+
args.push('--target', process.env.RUST_TARGET);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
run(args.join(' '));
|
|
22
|
+
preparePrebuilds(path.resolve(process.cwd()));
|
|
23
|
+
run('npm run build:types');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const BINARY_NAME = 'librespot_addon';
|
|
5
|
+
|
|
6
|
+
function preparePrebuilds(cwd = process.cwd()) {
|
|
7
|
+
const files = fs
|
|
8
|
+
.readdirSync(cwd)
|
|
9
|
+
.filter((file) => file.startsWith(`${BINARY_NAME}.`) && file.endsWith('.node'));
|
|
10
|
+
|
|
11
|
+
const moved = [];
|
|
12
|
+
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
if (file === `${BINARY_NAME}.node`) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const suffix = file.slice(BINARY_NAME.length + 1, -'.node'.length);
|
|
19
|
+
if (!suffix) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const destDir = path.join(cwd, 'prebuilds', suffix);
|
|
24
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const sourcePath = path.join(cwd, file);
|
|
27
|
+
const destPath = path.join(destDir, `${BINARY_NAME}.node`);
|
|
28
|
+
fs.renameSync(sourcePath, destPath);
|
|
29
|
+
|
|
30
|
+
moved.push(path.relative(cwd, destPath));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!moved.length) {
|
|
34
|
+
throw new Error(`No prebuild outputs found for ${BINARY_NAME}. Run "napi build --platform --release" first.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`Prebuilds ready:\n${moved.map((p) => ` - ${p}`).join('\n')}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (require.main === module) {
|
|
41
|
+
preparePrebuilds();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { preparePrebuilds };
|