iobroker.squeezeboxrpc 2.0.2 → 3.0.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/README.md +29 -18
- package/admin/i18n/de.json +7 -0
- package/admin/i18n/en.json +7 -0
- package/admin/i18n/es.json +7 -0
- package/admin/i18n/fr.json +7 -0
- package/admin/i18n/it.json +7 -0
- package/admin/i18n/nl.json +7 -0
- package/admin/i18n/pl.json +7 -0
- package/admin/i18n/pt.json +7 -0
- package/admin/i18n/ru.json +7 -0
- package/admin/i18n/uk.json +7 -0
- package/admin/i18n/zh-cn.json +7 -0
- package/admin/jsonConfig.json +36 -0
- package/io-package.json +26 -52
- package/lib/iosbplayer.js +3 -4
- package/lib/iosbserver.js +75 -180
- package/lib/lms/client.js +22 -0
- package/lib/lms/connection.js +104 -0
- package/lib/lms/discovery.js +145 -0
- package/lib/lms/jsonRpcClient.js +139 -0
- package/lib/lms/telnetEventClient.js +122 -0
- package/lib/lms/webSocketClient.js +352 -0
- package/package.json +4 -7
- package/widgets/squeezeboxrpc/build/squeezeboxrpc-dist.js +1 -1
- package/widgets/squeezeboxrpc/build/squeezeboxrpc-dist.js.map +1 -1
- package/lib/squeezenode/squeezeplayer.js +0 -480
- package/lib/squeezenode/squeezerequest.js +0 -273
- package/lib/squeezenode/squeezeserver.js +0 -352
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const https = require('node:https');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
8
|
+
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal LMS HTTP JSON-RPC client.
|
|
12
|
+
*
|
|
13
|
+
* The callback response intentionally keeps the shape used by the adapter
|
|
14
|
+
* while the adapter call sites are migrated to promises independently.
|
|
15
|
+
*/
|
|
16
|
+
class LmsJsonRpcClient {
|
|
17
|
+
constructor({ host, port, username = '', password = '', protocol = 'http:', timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
18
|
+
this.url = new URL('/jsonrpc.js', `${protocol}//${host}:${port}`);
|
|
19
|
+
this.timeoutMs = timeoutMs;
|
|
20
|
+
this.nextRequestId = 1;
|
|
21
|
+
this.closed = false;
|
|
22
|
+
this.authorization =
|
|
23
|
+
username && password ? `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` : '';
|
|
24
|
+
this.agent =
|
|
25
|
+
this.url.protocol === 'https:'
|
|
26
|
+
? new https.Agent({ keepAlive: true, maxSockets: 8 })
|
|
27
|
+
: new http.Agent({ keepAlive: true, maxSockets: 8 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
request(playerId, command, callback) {
|
|
31
|
+
const promise = this.requestAsync(playerId, command);
|
|
32
|
+
if (callback) {
|
|
33
|
+
promise.then(callback, error => callback(this.toLegacyError(error)));
|
|
34
|
+
}
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
requestAsync(playerId, command) {
|
|
39
|
+
if (this.closed) {
|
|
40
|
+
return Promise.reject(new Error('LMS JSON-RPC client is closed'));
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(command)) {
|
|
43
|
+
return Promise.reject(new TypeError('LMS command must be an array'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const params = [playerId || '', command];
|
|
47
|
+
const payload = JSON.stringify({
|
|
48
|
+
id: `squeezeboxrpc.${process.pid}.${this.nextRequestId++}`,
|
|
49
|
+
method: 'slim.request',
|
|
50
|
+
params,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const transport = this.url.protocol === 'https:' ? https : http;
|
|
55
|
+
const headers = {
|
|
56
|
+
Accept: 'application/json',
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
59
|
+
};
|
|
60
|
+
if (this.authorization) {
|
|
61
|
+
headers.Authorization = this.authorization;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const request = transport.request(
|
|
65
|
+
this.url,
|
|
66
|
+
{
|
|
67
|
+
method: 'POST',
|
|
68
|
+
agent: this.agent,
|
|
69
|
+
headers,
|
|
70
|
+
},
|
|
71
|
+
response => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let responseBytes = 0;
|
|
74
|
+
|
|
75
|
+
response.on('data', chunk => {
|
|
76
|
+
responseBytes += chunk.length;
|
|
77
|
+
if (responseBytes > MAX_RESPONSE_BYTES) {
|
|
78
|
+
request.destroy(new Error('LMS JSON-RPC response is too large'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
});
|
|
83
|
+
response.on('end', () => {
|
|
84
|
+
const statusCode = response.statusCode || 0;
|
|
85
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
86
|
+
reject(new Error(`LMS JSON-RPC HTTP status ${statusCode || 'unknown'}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
91
|
+
if (!body) {
|
|
92
|
+
reject(new Error('LMS JSON-RPC returned an empty response'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let reply;
|
|
97
|
+
try {
|
|
98
|
+
reply = JSON.parse(body);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
reject(new Error(`Invalid LMS JSON-RPC response: ${error.message}`, { cause: error }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (reply.error) {
|
|
105
|
+
const message = reply.error.message || reply.error.data || String(reply.error);
|
|
106
|
+
reject(new Error(`LMS JSON-RPC error: ${message}`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(reply, 'result')) {
|
|
110
|
+
reject(new Error('LMS JSON-RPC response has no result'));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resolve({ ...reply, params: reply.params || params, ok: true });
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
request.setTimeout(this.timeoutMs, () => request.destroy(new Error('LMS JSON-RPC request timed out')));
|
|
120
|
+
request.on('error', reject);
|
|
121
|
+
request.end(payload);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
toLegacyError(error) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: error instanceof Error ? error.message : String(error || 'Unknown LMS JSON-RPC error'),
|
|
129
|
+
error,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
close() {
|
|
134
|
+
this.closed = true;
|
|
135
|
+
this.agent.destroy();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { LmsJsonRpcClient };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('node:net');
|
|
4
|
+
|
|
5
|
+
const PLAYER_EVENT = /^((?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}))\s*(.*)$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Decodes one LMS CLI response line without allowing malformed URI escapes to
|
|
9
|
+
* terminate the event connection.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} line Raw LMS CLI line.
|
|
12
|
+
* @returns {string} Decoded line, or the original line when decoding fails.
|
|
13
|
+
*/
|
|
14
|
+
function decodeLine(line) {
|
|
15
|
+
try {
|
|
16
|
+
return decodeURIComponent(line);
|
|
17
|
+
} catch {
|
|
18
|
+
return line;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses one LMS CLI notification.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} line Raw LMS CLI line.
|
|
26
|
+
* @returns {{ playerId: string, command: string } | null} Parsed player event.
|
|
27
|
+
*/
|
|
28
|
+
function parsePlayerEvent(line) {
|
|
29
|
+
const match = PLAYER_EVENT.exec(decodeLine(line));
|
|
30
|
+
if (!match) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return { playerId: match[1], command: match[2].trim() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** LMS CLI notification connection used alongside the JSON-RPC client. */
|
|
37
|
+
class LmsTelnetEventClient {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} options Client options.
|
|
40
|
+
* @param {string} options.host LMS host.
|
|
41
|
+
* @param {number} options.port LMS CLI port.
|
|
42
|
+
* @param {string} [options.username] LMS username.
|
|
43
|
+
* @param {string} [options.password] LMS password.
|
|
44
|
+
* @param {(event: { playerId: string, command: string }) => void} options.onEvent Event callback.
|
|
45
|
+
* @param {(message: string) => void} [options.logDebug] Debug logger.
|
|
46
|
+
* @param {(options: object, listener: () => void) => import('node:net').Socket} [options.socketFactory]
|
|
47
|
+
* Socket factory used by tests.
|
|
48
|
+
*/
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.host = options.host;
|
|
51
|
+
this.port = options.port;
|
|
52
|
+
this.username = options.username || '';
|
|
53
|
+
this.password = options.password || '';
|
|
54
|
+
this.onEvent = options.onEvent;
|
|
55
|
+
this.logDebug = options.logDebug || (() => undefined);
|
|
56
|
+
this.socketFactory = options.socketFactory || net.createConnection;
|
|
57
|
+
this.socket = null;
|
|
58
|
+
this.receiveBuffer = '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Opens the LMS CLI connection and enables notification delivery. */
|
|
62
|
+
start() {
|
|
63
|
+
if (this.socket) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const socket = this.socketFactory({ host: this.host, port: this.port }, () => {
|
|
68
|
+
this.logDebug('Telnet event client connected to LMS');
|
|
69
|
+
if (this.username || this.password) {
|
|
70
|
+
socket.write(`login ${this.username} ${this.password}\r\n`);
|
|
71
|
+
}
|
|
72
|
+
socket.write('listen 1\r\n');
|
|
73
|
+
});
|
|
74
|
+
this.socket = socket;
|
|
75
|
+
|
|
76
|
+
socket.on('data', data => this.#handleData(data));
|
|
77
|
+
socket.on('end', () => this.logDebug('Telnet event client disconnected from LMS'));
|
|
78
|
+
socket.on('timeout', () => {
|
|
79
|
+
this.logDebug('Telnet event client timed out, closing socket');
|
|
80
|
+
socket.destroy();
|
|
81
|
+
});
|
|
82
|
+
socket.on('error', error => this.logDebug(`Telnet event client socket error: ${error.message}`));
|
|
83
|
+
socket.on('close', hadError => {
|
|
84
|
+
this.logDebug(`Telnet event client socket closed${hadError ? ' after an error' : ''}`);
|
|
85
|
+
if (this.socket === socket) {
|
|
86
|
+
this.socket = null;
|
|
87
|
+
}
|
|
88
|
+
this.receiveBuffer = '';
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {Buffer|string} data Incoming TCP data.
|
|
94
|
+
*/
|
|
95
|
+
#handleData(data) {
|
|
96
|
+
const chunk = data.toString();
|
|
97
|
+
this.logDebug(`Telnet event client received data: ${chunk}`);
|
|
98
|
+
this.receiveBuffer += chunk;
|
|
99
|
+
|
|
100
|
+
let lineEnd;
|
|
101
|
+
while ((lineEnd = this.receiveBuffer.indexOf('\n')) !== -1) {
|
|
102
|
+
const line = this.receiveBuffer.slice(0, lineEnd).replace(/\r$/, '');
|
|
103
|
+
this.receiveBuffer = this.receiveBuffer.slice(lineEnd + 1);
|
|
104
|
+
const event = parsePlayerEvent(line);
|
|
105
|
+
if (event) {
|
|
106
|
+
this.onEvent(event);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Ends the LMS CLI connection. Safe to call more than once. */
|
|
112
|
+
close() {
|
|
113
|
+
const socket = this.socket;
|
|
114
|
+
this.socket = null;
|
|
115
|
+
this.receiveBuffer = '';
|
|
116
|
+
if (socket && !socket.destroyed) {
|
|
117
|
+
socket.end();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { LmsTelnetEventClient, parsePlayerEvent };
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
|
+
const DEFAULT_RECONNECT_MS = 3_000;
|
|
7
|
+
const ERROR_LOG_INTERVAL_MS = 60_000;
|
|
8
|
+
const LIST_PAGE_SIZE = 100;
|
|
9
|
+
const PAGED_COMMANDS = new Set([
|
|
10
|
+
'albums',
|
|
11
|
+
'artists',
|
|
12
|
+
'favorites',
|
|
13
|
+
'genres',
|
|
14
|
+
'menu',
|
|
15
|
+
'musicfolder',
|
|
16
|
+
'myapps',
|
|
17
|
+
'playlists',
|
|
18
|
+
'radios',
|
|
19
|
+
'songs',
|
|
20
|
+
'titles',
|
|
21
|
+
'tracks',
|
|
22
|
+
'works',
|
|
23
|
+
'years',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the WebSocket endpoint from an optional independent web-server URL.
|
|
28
|
+
* HTTP(S) URLs are accepted for convenient configuration and converted to
|
|
29
|
+
* WS(S). A root path is completed with the LMS plugin endpoint `/ws`.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options Client options.
|
|
32
|
+
* @param {string} options.host LMS host.
|
|
33
|
+
* @param {number} options.port LMS HTTP port.
|
|
34
|
+
* @param {string} [options.webSocketUrl] Independent WebSocket web-server URL.
|
|
35
|
+
* @returns {URL} Normalized WebSocket endpoint.
|
|
36
|
+
*/
|
|
37
|
+
function createWebSocketUrl(options) {
|
|
38
|
+
let configuredUrl = options.webSocketUrl?.trim();
|
|
39
|
+
if (!configuredUrl) {
|
|
40
|
+
return new URL('/ws', `ws://${options.host}:${options.port}`);
|
|
41
|
+
}
|
|
42
|
+
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(configuredUrl)) {
|
|
43
|
+
configuredUrl = `ws://${configuredUrl}`;
|
|
44
|
+
}
|
|
45
|
+
const url = new URL(configuredUrl);
|
|
46
|
+
if (url.protocol === 'http:') {
|
|
47
|
+
url.protocol = 'ws:';
|
|
48
|
+
} else if (url.protocol === 'https:') {
|
|
49
|
+
url.protocol = 'wss:';
|
|
50
|
+
} else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
|
51
|
+
throw new Error('LMS WebSocket URL must use HTTP(S) or WS(S)');
|
|
52
|
+
}
|
|
53
|
+
if (url.pathname === '/') {
|
|
54
|
+
url.pathname = '/ws';
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Experimental LMS WebSocket command and notification client. */
|
|
60
|
+
class LmsWebSocketClient {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} options Client options.
|
|
63
|
+
* @param {string} options.host LMS host.
|
|
64
|
+
* @param {number} options.port LMS HTTP/WebSocket port.
|
|
65
|
+
* @param {string} [options.username] LMS username.
|
|
66
|
+
* @param {string} [options.password] LMS password.
|
|
67
|
+
* @param {string} [options.webSocketUrl] Independent WebSocket URL.
|
|
68
|
+
* @param {(event: {playerId: string, command: string, data: object}) => void} [options.onEvent]
|
|
69
|
+
* Notification callback.
|
|
70
|
+
* @param {(message: string) => void} [options.logDebug] Debug logger.
|
|
71
|
+
* @param {(message: string) => void} [options.logError] Error logger.
|
|
72
|
+
* @param {number} [options.timeoutMs] Request timeout.
|
|
73
|
+
* @param {number} [options.reconnectMs] Reconnect delay.
|
|
74
|
+
* @param {typeof setTimeout} [options.setTimeout] Adapter-managed timer creator.
|
|
75
|
+
* @param {typeof clearTimeout} [options.clearTimeout] Adapter-managed timer cleanup.
|
|
76
|
+
* @param {typeof WebSocket} [options.webSocketClass] WebSocket implementation used by tests.
|
|
77
|
+
*/
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.url = createWebSocketUrl(options);
|
|
80
|
+
this.origin = `${this.url.protocol === 'wss:' ? 'https:' : 'http:'}//${this.url.host}`;
|
|
81
|
+
this.username = options.username || '';
|
|
82
|
+
this.password = options.password || '';
|
|
83
|
+
this.onEvent = options.onEvent || (() => undefined);
|
|
84
|
+
this.logDebug = options.logDebug || (() => undefined);
|
|
85
|
+
this.logError = options.logError || this.logDebug;
|
|
86
|
+
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
87
|
+
this.reconnectMs = options.reconnectMs || DEFAULT_RECONNECT_MS;
|
|
88
|
+
this.setTimeout = options.setTimeout || global.setTimeout;
|
|
89
|
+
this.clearTimeout = options.clearTimeout || global.clearTimeout;
|
|
90
|
+
this.WebSocketClass = options.webSocketClass || WebSocket;
|
|
91
|
+
this.socket = null;
|
|
92
|
+
this.connectPromise = null;
|
|
93
|
+
this.reconnectTimer = null;
|
|
94
|
+
this.nextRequestId = 1;
|
|
95
|
+
this.pendingRequests = new Map();
|
|
96
|
+
this.closed = false;
|
|
97
|
+
this.lastErrorLog = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Starts connecting. Commands also connect lazily when necessary. */
|
|
101
|
+
start() {
|
|
102
|
+
void this.#ensureConnected().catch(error => this.#reportConnectionError(error));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sends an LMS command and optionally reports the result through a callback.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} playerId LMS player ID, or an empty string for server commands.
|
|
109
|
+
* @param {unknown[]} command LMS command tokens.
|
|
110
|
+
* @param {(result: object) => void} [callback] Compatibility callback.
|
|
111
|
+
* @returns {Promise<object>} Pending command result.
|
|
112
|
+
*/
|
|
113
|
+
request(playerId, command, callback) {
|
|
114
|
+
const promise = this.requestAsync(playerId, command);
|
|
115
|
+
if (callback) {
|
|
116
|
+
promise.then(callback, error => callback(this.#toLegacyError(error)));
|
|
117
|
+
}
|
|
118
|
+
return promise;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sends an LMS command over the WebSocket plugin endpoint.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} playerId LMS player ID, or an empty string for server commands.
|
|
125
|
+
* @param {unknown[]} command LMS command tokens.
|
|
126
|
+
* @returns {Promise<object>} Pending command result.
|
|
127
|
+
*/
|
|
128
|
+
async requestAsync(playerId, command) {
|
|
129
|
+
if (!Array.isArray(command)) {
|
|
130
|
+
throw new TypeError('LMS command must be an array');
|
|
131
|
+
}
|
|
132
|
+
const rangeIndex = this.#findLargeRange(command);
|
|
133
|
+
if (rangeIndex !== -1) {
|
|
134
|
+
return this.#requestPaged(playerId, command, rangeIndex);
|
|
135
|
+
}
|
|
136
|
+
return this.#requestSingle(playerId, command);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #requestSingle(playerId, command) {
|
|
140
|
+
const socket = await this.#ensureConnected();
|
|
141
|
+
const id = this.nextRequestId++;
|
|
142
|
+
const params = [playerId || '', command];
|
|
143
|
+
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timeout = this.setTimeout(() => {
|
|
146
|
+
this.pendingRequests.delete(id);
|
|
147
|
+
reject(new Error('LMS WebSocket request timed out'));
|
|
148
|
+
}, this.timeoutMs);
|
|
149
|
+
this.pendingRequests.set(id, { resolve, reject, timeout, params });
|
|
150
|
+
try {
|
|
151
|
+
socket.send(JSON.stringify({ id, request: params }));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.clearTimeout(timeout);
|
|
154
|
+
this.pendingRequests.delete(id);
|
|
155
|
+
reject(error);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#findLargeRange(command) {
|
|
161
|
+
if (!PAGED_COMMANDS.has(String(command[0]))) {
|
|
162
|
+
return -1;
|
|
163
|
+
}
|
|
164
|
+
for (let index = 1; index < Math.min(command.length - 1, 4); index++) {
|
|
165
|
+
const start = Number(command[index]);
|
|
166
|
+
const count = Number(command[index + 1]);
|
|
167
|
+
if (Number.isInteger(start) && start >= 0 && Number.isInteger(count) && count > LIST_PAGE_SIZE) {
|
|
168
|
+
return index;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return -1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async #requestPaged(playerId, command, rangeIndex) {
|
|
175
|
+
const originalStart = Number(command[rangeIndex]);
|
|
176
|
+
const requestedCount = Number(command[rangeIndex + 1]);
|
|
177
|
+
let offset = originalStart;
|
|
178
|
+
let combined;
|
|
179
|
+
let loopKey;
|
|
180
|
+
|
|
181
|
+
while (offset - originalStart < requestedCount) {
|
|
182
|
+
const pageCommand = [...command];
|
|
183
|
+
pageCommand[rangeIndex] = offset;
|
|
184
|
+
pageCommand[rangeIndex + 1] = Math.min(LIST_PAGE_SIZE, requestedCount - (offset - originalStart));
|
|
185
|
+
const page = await this.#requestSingle(playerId, pageCommand);
|
|
186
|
+
const pageResult = page.result;
|
|
187
|
+
const currentLoopKey = Object.keys(pageResult).find(
|
|
188
|
+
key => key.endsWith('_loop') && Array.isArray(pageResult[key]),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!combined) {
|
|
192
|
+
combined = { ...page, result: { ...pageResult }, params: [playerId || '', command] };
|
|
193
|
+
loopKey = currentLoopKey;
|
|
194
|
+
if (loopKey) {
|
|
195
|
+
combined.result[loopKey] = [...pageResult[loopKey]];
|
|
196
|
+
}
|
|
197
|
+
} else if (loopKey && currentLoopKey === loopKey) {
|
|
198
|
+
combined.result[loopKey].push(...pageResult[loopKey]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const received = currentLoopKey ? pageResult[currentLoopKey].length : 0;
|
|
202
|
+
offset += received;
|
|
203
|
+
if (!currentLoopKey || received < pageCommand[rangeIndex + 1]) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return combined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Permanently closes this client and rejects pending commands. */
|
|
211
|
+
close() {
|
|
212
|
+
this.closed = true;
|
|
213
|
+
if (this.reconnectTimer) {
|
|
214
|
+
this.clearTimeout(this.reconnectTimer);
|
|
215
|
+
this.reconnectTimer = null;
|
|
216
|
+
}
|
|
217
|
+
this.#rejectPending(new Error('LMS WebSocket client is closed'));
|
|
218
|
+
const socket = this.socket;
|
|
219
|
+
this.socket = null;
|
|
220
|
+
this.connectPromise = null;
|
|
221
|
+
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
|
222
|
+
socket.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async #ensureConnected() {
|
|
227
|
+
if (this.closed) {
|
|
228
|
+
throw new Error('LMS WebSocket client is closed');
|
|
229
|
+
}
|
|
230
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
231
|
+
return this.socket;
|
|
232
|
+
}
|
|
233
|
+
if (this.connectPromise) {
|
|
234
|
+
return this.connectPromise;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
238
|
+
const headers = {};
|
|
239
|
+
if (this.username && this.password) {
|
|
240
|
+
headers.Authorization = `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`;
|
|
241
|
+
}
|
|
242
|
+
const socket = new this.WebSocketClass(this.url, { headers, origin: this.origin });
|
|
243
|
+
this.socket = socket;
|
|
244
|
+
let settled = false;
|
|
245
|
+
|
|
246
|
+
socket.once('open', () => {
|
|
247
|
+
settled = true;
|
|
248
|
+
this.connectPromise = null;
|
|
249
|
+
this.lastErrorLog = 0;
|
|
250
|
+
this.logDebug('Connected to LMS WebSocket');
|
|
251
|
+
socket.send(JSON.stringify({ id: this.nextRequestId++, listen: 1 }));
|
|
252
|
+
resolve(socket);
|
|
253
|
+
});
|
|
254
|
+
socket.on('message', data => this.#handleMessage(data));
|
|
255
|
+
socket.once('error', error => {
|
|
256
|
+
this.logDebug(`LMS WebSocket error: ${error.message}`);
|
|
257
|
+
if (!settled) {
|
|
258
|
+
settled = true;
|
|
259
|
+
this.connectPromise = null;
|
|
260
|
+
reject(error);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
socket.once('close', () => {
|
|
264
|
+
this.logDebug('LMS WebSocket disconnected');
|
|
265
|
+
if (this.socket === socket) {
|
|
266
|
+
this.socket = null;
|
|
267
|
+
}
|
|
268
|
+
if (!settled) {
|
|
269
|
+
settled = true;
|
|
270
|
+
this.connectPromise = null;
|
|
271
|
+
reject(new Error('LMS WebSocket closed before connecting'));
|
|
272
|
+
}
|
|
273
|
+
this.#rejectPending(new Error('LMS WebSocket connection closed'));
|
|
274
|
+
this.#scheduleReconnect();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
return this.connectPromise;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#handleMessage(data) {
|
|
281
|
+
let message;
|
|
282
|
+
try {
|
|
283
|
+
message = JSON.parse(data.toString());
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.logDebug(`Invalid LMS WebSocket message: ${error.message}`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (Array.isArray(message.event)) {
|
|
290
|
+
const [playerId = '', command = [], eventData = {}] = message.event;
|
|
291
|
+
this.onEvent({
|
|
292
|
+
playerId: String(playerId),
|
|
293
|
+
command: Array.isArray(command) ? command.map(String).join(' ') : String(command),
|
|
294
|
+
data: eventData && typeof eventData === 'object' ? eventData : {},
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const pending = this.pendingRequests.get(message.id);
|
|
300
|
+
if (!pending) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.clearTimeout(pending.timeout);
|
|
304
|
+
this.pendingRequests.delete(message.id);
|
|
305
|
+
if (message.error) {
|
|
306
|
+
const errorMessage = message.error.message || message.error.data || String(message.error);
|
|
307
|
+
pending.reject(new Error(`LMS WebSocket error: ${errorMessage}`));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!Object.prototype.hasOwnProperty.call(message, 'result')) {
|
|
311
|
+
pending.reject(new Error('LMS WebSocket response has no result'));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
pending.resolve({ ...message, params: message.params || pending.params, ok: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#rejectPending(error) {
|
|
318
|
+
for (const pending of this.pendingRequests.values()) {
|
|
319
|
+
this.clearTimeout(pending.timeout);
|
|
320
|
+
pending.reject(error);
|
|
321
|
+
}
|
|
322
|
+
this.pendingRequests.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#scheduleReconnect() {
|
|
326
|
+
if (this.closed || this.reconnectTimer) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.reconnectTimer = this.setTimeout(() => {
|
|
330
|
+
this.reconnectTimer = null;
|
|
331
|
+
this.start();
|
|
332
|
+
}, this.reconnectMs);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#reportConnectionError(error) {
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
if (now - this.lastErrorLog >= ERROR_LOG_INTERVAL_MS) {
|
|
338
|
+
this.lastErrorLog = now;
|
|
339
|
+
this.logError(`LMS WebSocket connection to ${this.url.href} failed: ${error.message}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#toLegacyError(error) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
message: error instanceof Error ? error.message : String(error || 'Unknown LMS WebSocket error'),
|
|
347
|
+
error,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
module.exports = { createWebSocketUrl, LmsWebSocketClient };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.squeezeboxrpc",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "ioBroker Logitech Squeezebox Adapter over JSON/RPC-Protocol",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "oweitman",
|
|
@@ -25,9 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@iobroker/adapter-core": "^3.4.3",
|
|
28
|
-
"
|
|
29
|
-
"lodash": "^4.18.1",
|
|
30
|
-
"super": "^0.2.3"
|
|
28
|
+
"ws": "^8.21.3"
|
|
31
29
|
},
|
|
32
30
|
"devDependencies": {
|
|
33
31
|
"@alcalzone/release-script": "^5.2.1",
|
|
@@ -84,12 +82,11 @@
|
|
|
84
82
|
"dev:watchvis2widgets": "npm run start --prefix src-widgets",
|
|
85
83
|
"dev:watchvis1widgets": "npm run watch --prefix widgets",
|
|
86
84
|
"prepublishOnly": "npm run build",
|
|
87
|
-
"repochecker": "npx
|
|
85
|
+
"repochecker": "npx @iobroker/repochecker https://github.com/oweitman/ioBroker.squeezeboxrpc --local --env=../iobroker.env",
|
|
88
86
|
"translate": "translate-adapter",
|
|
89
87
|
"translate-widgets": "node ./bin/translate.js --source widgets/squeezeboxrpc/myi18n/en.json --format single",
|
|
90
88
|
"translate-lib": "node ./bin/translate.js --source lib/i18n/ --format multi",
|
|
91
|
-
"release": "release-script --all"
|
|
92
|
-
"release:2x": "release-script --all --branch-pattern release2x"
|
|
89
|
+
"release": "release-script --all"
|
|
93
90
|
},
|
|
94
91
|
"bugs": {
|
|
95
92
|
"url": "https://github.com/oweitman/ioBroker.squeezeboxrpc/issues"
|