stagelinq 1.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/.gitattributes +2 -0
- package/.prettierignore +7 -0
- package/.prettierrc.js +10 -0
- package/.vscode/launch.json +40 -0
- package/.vscode/tasks.json +24 -0
- package/Databases/Databases.ts +69 -0
- package/Databases/DbConnection.ts +42 -0
- package/Databases/index.ts +2 -0
- package/LICENSE +674 -0
- package/LogEmitter/index.ts +51 -0
- package/README.md +83 -0
- package/StageLinq/index.ts +58 -0
- package/TODO.md +5 -0
- package/albumArt/index.ts +2 -0
- package/albumArt/maybeDownloadFiles.ts +34 -0
- package/cli/index.ts +142 -0
- package/devices/Player.ts +182 -0
- package/devices/PlayerMessageQueue.ts +62 -0
- package/index.ts +5 -0
- package/network/NetworkDevice.ts +287 -0
- package/network/StageLinqDevices.ts +215 -0
- package/network/StageLinqListener.ts +50 -0
- package/network/announce.ts +133 -0
- package/network/index.ts +4 -0
- package/package.json +31 -0
- package/services/FileTransfer.ts +298 -0
- package/services/Service.ts +144 -0
- package/services/StateMap.ts +170 -0
- package/services/index.ts +3 -0
- package/stagelinq.code-workspace +16 -0
- package/tsconfig.json +13 -0
- package/types/common.ts +240 -0
- package/types/database.ts +50 -0
- package/types/index.ts +53 -0
- package/types/player.ts +41 -0
- package/types/tokens.ts +39 -0
- package/utils/Context.ts +45 -0
- package/utils/ReadContext.ts +114 -0
- package/utils/WriteContext.ts +105 -0
- package/utils/getTempFilePath.ts +23 -0
- package/utils/hex.ts +62 -0
- package/utils/index.ts +6 -0
- package/utils/log.ts +0 -0
- package/utils/sleep.ts +3 -0
- package/utils/tcp.ts +17 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Logger } from '../LogEmitter';
|
|
2
|
+
import { ReadContext } from '../utils/ReadContext';
|
|
3
|
+
import { ServicePorts, ConnectionInfo, LISTEN_TIMEOUT, MessageId, Tokens } from '../types';
|
|
4
|
+
import { sleep } from '../utils/sleep';
|
|
5
|
+
import { strict as assert } from 'assert';
|
|
6
|
+
import { WriteContext } from '../utils/WriteContext';
|
|
7
|
+
import * as FileType from 'file-type';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as services from '../services';
|
|
10
|
+
import * as tcp from '../utils/tcp';
|
|
11
|
+
import Database = require('better-sqlite3');
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
interface SourceAndTrackPath {
|
|
15
|
+
source: string;
|
|
16
|
+
trackPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class NetworkDevice {
|
|
20
|
+
private connection: tcp.Connection = null;
|
|
21
|
+
//private source: string = null;
|
|
22
|
+
private serviceRequestAllowed = false;
|
|
23
|
+
private servicePorts: ServicePorts = {};
|
|
24
|
+
private services: Record<string, InstanceType<typeof services.Service>> = {};
|
|
25
|
+
private timeAlive: number = 0;
|
|
26
|
+
private connectedSources: {
|
|
27
|
+
[key: string]: {
|
|
28
|
+
db: Database.Database;
|
|
29
|
+
albumArt: {
|
|
30
|
+
path: string;
|
|
31
|
+
extensions: {
|
|
32
|
+
[key: string]: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
} = {};
|
|
37
|
+
|
|
38
|
+
private connectionInfo: ConnectionInfo;
|
|
39
|
+
|
|
40
|
+
constructor(info: ConnectionInfo) {
|
|
41
|
+
this.connectionInfo = info;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private get address() {
|
|
45
|
+
return this.connectionInfo.address;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private get port() {
|
|
49
|
+
return this.connectionInfo.port;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
///////////////////////////////////////////////////////////////////////////
|
|
53
|
+
// Connect / Disconnect
|
|
54
|
+
|
|
55
|
+
async connect(): Promise<void> {
|
|
56
|
+
const info = this.connectionInfo;
|
|
57
|
+
Logger.debug(`Attempting to connect to ${info.address}:${info.port}`)
|
|
58
|
+
this.connection = await tcp.connect(info.address, info.port);
|
|
59
|
+
this.connection.socket.on('data', (p_message: Buffer) => {
|
|
60
|
+
this.messageHandler(p_message);
|
|
61
|
+
});
|
|
62
|
+
await this.requestAllServicePorts();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
disconnect(): void {
|
|
66
|
+
// Disconnect all services
|
|
67
|
+
for (const [key, service] of Object.entries(this.services)) {
|
|
68
|
+
service.disconnect();
|
|
69
|
+
this.services[key] = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
assert(this.connection);
|
|
73
|
+
this.connection.destroy();
|
|
74
|
+
this.connection = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
///////////////////////////////////////////////////////////////////////////
|
|
78
|
+
// Message Handler
|
|
79
|
+
|
|
80
|
+
messageHandler(p_message: Buffer): void {
|
|
81
|
+
const ctx = new ReadContext(p_message.buffer, false);
|
|
82
|
+
while (ctx.isEOF() === false) {
|
|
83
|
+
const id = ctx.readUInt32();
|
|
84
|
+
// FIXME: Verify token
|
|
85
|
+
ctx.seek(16); // Skip token; present in all messages
|
|
86
|
+
switch (id) {
|
|
87
|
+
case MessageId.TimeStamp:
|
|
88
|
+
ctx.seek(16); // Skip token; present in all messages
|
|
89
|
+
// Time Alive is in nanoseconds; convert back to seconds
|
|
90
|
+
this.timeAlive = Number(ctx.readUInt64() / (1000n * 1000n * 1000n));
|
|
91
|
+
break;
|
|
92
|
+
case MessageId.ServicesAnnouncement:
|
|
93
|
+
const service = ctx.readNetworkStringUTF16();
|
|
94
|
+
const port = ctx.readUInt16();
|
|
95
|
+
this.servicePorts[service] = port;
|
|
96
|
+
break;
|
|
97
|
+
case MessageId.ServicesRequest:
|
|
98
|
+
this.serviceRequestAllowed = true;
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
assert.fail(`NetworkDevice Unhandled message id '${id}'`);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
///////////////////////////////////////////////////////////////////////////
|
|
108
|
+
// Public methods
|
|
109
|
+
|
|
110
|
+
getPort(): number {
|
|
111
|
+
return this.port;
|
|
112
|
+
}
|
|
113
|
+
getTimeAlive(): number {
|
|
114
|
+
return this.timeAlive;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Factory function
|
|
118
|
+
async connectToService<T extends InstanceType<typeof services.Service>>(ctor: {
|
|
119
|
+
new (p_address: string, p_port: number, p_controller: NetworkDevice): T;
|
|
120
|
+
}): Promise<T> {
|
|
121
|
+
assert(this.connection);
|
|
122
|
+
// FIXME: find out why we need these waits before connecting to a service
|
|
123
|
+
await sleep(500);
|
|
124
|
+
|
|
125
|
+
const serviceName = ctor.name;
|
|
126
|
+
|
|
127
|
+
if (this.services[serviceName]) {
|
|
128
|
+
return this.services[serviceName] as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assert(this.servicePorts.hasOwnProperty(serviceName));
|
|
132
|
+
assert(this.servicePorts[serviceName] > 0);
|
|
133
|
+
const port = this.servicePorts[serviceName];
|
|
134
|
+
|
|
135
|
+
const service = new ctor(this.address, port, this);
|
|
136
|
+
|
|
137
|
+
await service.connect();
|
|
138
|
+
this.services[serviceName] = service;
|
|
139
|
+
return service;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// TODO: Refactor this out of here.
|
|
143
|
+
async addSource(p_sourceName: string, p_localDbPath: string, p_localAlbumArtPath: string) {
|
|
144
|
+
if (this.connectedSources[p_sourceName]) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const db = new Database(p_localDbPath);
|
|
148
|
+
|
|
149
|
+
// Get all album art extensions
|
|
150
|
+
const stmt = db.prepare('SELECT * FROM AlbumArt WHERE albumArt NOT NULL');
|
|
151
|
+
const result = stmt.all();
|
|
152
|
+
const albumArtExtensions: Record<string, string | null> = {};
|
|
153
|
+
for (const entry of result) {
|
|
154
|
+
const filetype = await FileType.fromBuffer(entry.albumArt);
|
|
155
|
+
albumArtExtensions[entry.id] = filetype ? filetype.ext : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.connectedSources[p_sourceName] = {
|
|
159
|
+
db: db,
|
|
160
|
+
albumArt: {
|
|
161
|
+
path: p_localAlbumArtPath,
|
|
162
|
+
extensions: albumArtExtensions,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// TODO: Refactor this out of here.
|
|
168
|
+
async dumpAlbumArt(p_sourceName: string) {
|
|
169
|
+
if (!this.connectedSources[p_sourceName]) {
|
|
170
|
+
assert.fail(`Source '${p_sourceName}' not connected`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const path = this.connectedSources[p_sourceName].albumArt.path;
|
|
174
|
+
if (fs.existsSync(path) === false) {
|
|
175
|
+
fs.mkdirSync(path, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = await this.querySource(p_sourceName, 'SELECT * FROM AlbumArt WHERE albumArt NOT NULL');
|
|
179
|
+
for (const entry of result) {
|
|
180
|
+
const filetype = await FileType.fromBuffer(entry.albumArt);
|
|
181
|
+
const ext = filetype ? '.' + filetype.ext : '';
|
|
182
|
+
const filepath = `${path}/${entry.id}${ext}`;
|
|
183
|
+
fs.writeFileSync(filepath, entry.albumArt);
|
|
184
|
+
}
|
|
185
|
+
Logger.info(`dumped ${result.length} albums arts in '${path}'`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Database helpers
|
|
189
|
+
|
|
190
|
+
querySource(p_sourceName: string, p_query: string, ...p_params: any[]): any[] {
|
|
191
|
+
if (!this.connectedSources[p_sourceName]) {
|
|
192
|
+
//assert.fail(`Source '${p_sourceName}' not connected`);
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const db = this.connectedSources[p_sourceName].db;
|
|
196
|
+
const stmt = db.prepare(p_query);
|
|
197
|
+
|
|
198
|
+
return stmt.all(p_params);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
getAlbumArtPath(p_networkPath: string): string {
|
|
202
|
+
const result = this.getSourceAndTrackFromNetworkPath(p_networkPath);
|
|
203
|
+
if (!result) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sql = 'SELECT * FROM Track WHERE path = ?';
|
|
208
|
+
const dbResult = this.querySource(result.source, sql, result.trackPath);
|
|
209
|
+
if (dbResult.length === 0) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
assert(dbResult.length === 1); // there can only be one path
|
|
214
|
+
const id = dbResult[0].idAlbumArt;
|
|
215
|
+
const ext = this.connectedSources[result.source].albumArt.extensions[id];
|
|
216
|
+
if (!ext) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return `${this.connectedSources[result.source].albumArt.path}${id}.${ext}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
///////////////////////////////////////////////////////////////////////////
|
|
224
|
+
// Private methods
|
|
225
|
+
|
|
226
|
+
private getSourceAndTrackFromNetworkPath(p_path: string): SourceAndTrackPath {
|
|
227
|
+
if (!p_path || p_path.length === 0) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parts = p_path.split('/');
|
|
232
|
+
//assert(parts.length > )
|
|
233
|
+
assert(parts[0] === 'net:');
|
|
234
|
+
assert(parts[1] === '');
|
|
235
|
+
assert(parts[2].length === 36);
|
|
236
|
+
const source = parts[3];
|
|
237
|
+
let trackPath = parts.slice(5).join('/');
|
|
238
|
+
if (parts[4] !== 'Engine Library') {
|
|
239
|
+
// This probably occurs with RekordBox conversions; tracks are outside Engine Library folder
|
|
240
|
+
trackPath = `../${parts[4]}/${trackPath}`;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
source: source,
|
|
244
|
+
trackPath: trackPath,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async requestAllServicePorts(): Promise<void> {
|
|
249
|
+
assert(this.connection);
|
|
250
|
+
|
|
251
|
+
return new Promise(async (resolve, reject) => {
|
|
252
|
+
setTimeout(() => {
|
|
253
|
+
reject(new Error(`Failed to requestServices for ` +
|
|
254
|
+
`${this.connectionInfo.source} ` +
|
|
255
|
+
`${this.connectionInfo.address}:${this.connectionInfo.port}`));
|
|
256
|
+
}, LISTEN_TIMEOUT);
|
|
257
|
+
|
|
258
|
+
// Wait for serviceRequestAllowed
|
|
259
|
+
while (true) {
|
|
260
|
+
if (this.serviceRequestAllowed) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
await sleep(250);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// FIXME: Refactor into message writer helper class
|
|
267
|
+
const ctx = new WriteContext();
|
|
268
|
+
ctx.writeUInt32(MessageId.ServicesRequest);
|
|
269
|
+
ctx.write(Tokens.SoundSwitch);
|
|
270
|
+
const written = await this.connection.write(ctx.getBuffer());
|
|
271
|
+
assert(written === ctx.tell());
|
|
272
|
+
|
|
273
|
+
while (true) {
|
|
274
|
+
// FIXME: How to determine when all services have been announced?
|
|
275
|
+
if (Object.keys(this.servicePorts).length > 3) {
|
|
276
|
+
Logger.debug(`Discovered the following services on ${this.address}:${this.port}`);
|
|
277
|
+
for (const [name, port] of Object.entries(this.servicePorts)) {
|
|
278
|
+
Logger.debug(`\tport: ${port} => ${name}`);
|
|
279
|
+
}
|
|
280
|
+
resolve();
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
await sleep(250);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ConnectionInfo, IpAddress, PlayerStatus, ServiceMessage, StageLinqOptions } from '../types';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { NetworkDevice } from '.';
|
|
4
|
+
import { Player } from '../devices/Player';
|
|
5
|
+
import { sleep } from '../utils';
|
|
6
|
+
import { FileTransfer, StateData, StateMap } from '../services';
|
|
7
|
+
import { Logger } from '../LogEmitter';
|
|
8
|
+
import { Databases } from '../Databases';
|
|
9
|
+
|
|
10
|
+
enum ConnectionStatus { CONNECTING, CONNECTED, FAILED };
|
|
11
|
+
|
|
12
|
+
interface StageLinqDevice {
|
|
13
|
+
networkDevice: NetworkDevice;
|
|
14
|
+
fileTransferService: FileTransfer;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export declare interface StageLinqDevices {
|
|
18
|
+
on(event: 'trackLoaded', listener: (status: PlayerStatus) => void): this;
|
|
19
|
+
on(event: 'stateChanged', listener: (status: PlayerStatus) => void): this;
|
|
20
|
+
on(event: 'nowPlaying', listener: (status: PlayerStatus) => void): this;
|
|
21
|
+
on(event: 'connected', listener: (connectionInfo: ConnectionInfo) => void): this;
|
|
22
|
+
on(event: 'message', listener: (connectionInfo: ConnectionInfo, message: ServiceMessage<StateData>) => void): this;
|
|
23
|
+
on(event: 'ready', listener: (connectionInfo: ConnectionInfo) => void): this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//////////////////////////////////////////////////////////////////////////////
|
|
27
|
+
|
|
28
|
+
// TODO: Refactor device, listener, and player into something more nicer.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handle connecting and disconnecting from discovered devices on the
|
|
32
|
+
* StageLinq network.
|
|
33
|
+
*/
|
|
34
|
+
export class StageLinqDevices extends EventEmitter {
|
|
35
|
+
|
|
36
|
+
private _databases: Databases;
|
|
37
|
+
private devices: Map<IpAddress, StageLinqDevice> = new Map();
|
|
38
|
+
private discoveryStatus: Map<string, ConnectionStatus> = new Map();
|
|
39
|
+
private options: StageLinqOptions;
|
|
40
|
+
|
|
41
|
+
constructor(options: StageLinqOptions) {
|
|
42
|
+
super();
|
|
43
|
+
this.options = options;
|
|
44
|
+
this._databases = new Databases();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Attempt to connect to the player.
|
|
49
|
+
*
|
|
50
|
+
* @param connectionInfo Device discovered
|
|
51
|
+
* @returns Retries to connect 3 times. If successful return void if not throw exception.
|
|
52
|
+
*/
|
|
53
|
+
async handleDevice(connectionInfo: ConnectionInfo) {
|
|
54
|
+
Logger.silly(this.showDiscoveryStatus(connectionInfo));
|
|
55
|
+
|
|
56
|
+
// Ignore this discovery message if connected, connecting, failed, or
|
|
57
|
+
// if it's blacklisted.
|
|
58
|
+
if (this.isConnected(connectionInfo)
|
|
59
|
+
|| this.isConnecting(connectionInfo)
|
|
60
|
+
|| this.isFailed(connectionInfo)
|
|
61
|
+
|| this.isIgnored(connectionInfo)) return;
|
|
62
|
+
|
|
63
|
+
this.discoveryStatus.set(this.deviceId(connectionInfo), ConnectionStatus.CONNECTING);
|
|
64
|
+
|
|
65
|
+
// Retrying appears to be necessary because it seems the Denon hardware
|
|
66
|
+
// sometimes doesn't connect. Retrying after a little wait seems to
|
|
67
|
+
// solve the issue.
|
|
68
|
+
|
|
69
|
+
let attempt = 1;
|
|
70
|
+
|
|
71
|
+
while (attempt < this.options.maxRetries) {
|
|
72
|
+
try {
|
|
73
|
+
Logger.info(`Connecting to ${this.deviceId(connectionInfo)}. ` +
|
|
74
|
+
`Attempt ${attempt}/${this.options.maxRetries}`);
|
|
75
|
+
// If this fails, catch it, and maybe retry if necessary.
|
|
76
|
+
await this.connectToPlayer(connectionInfo);
|
|
77
|
+
this.discoveryStatus.set(this.deviceId(connectionInfo), ConnectionStatus.CONNECTED);
|
|
78
|
+
this.emit('ready', connectionInfo);
|
|
79
|
+
return; // Don't forget to return!
|
|
80
|
+
} catch(e) {
|
|
81
|
+
|
|
82
|
+
// Failed connection. Sleep then retry.
|
|
83
|
+
Logger.warn(`Could not connect to ${this.deviceId(connectionInfo)} ` +
|
|
84
|
+
`(${attempt}/${this.options.maxRetries}): ${e}`);
|
|
85
|
+
attempt += 1;
|
|
86
|
+
sleep(500);
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// We failed 3 times. Throw exception.
|
|
92
|
+
this.discoveryStatus.set(this.deviceId(connectionInfo), ConnectionStatus.FAILED);
|
|
93
|
+
throw new Error(`Could not connect to ${this.deviceId(connectionInfo)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Disconnect from all connected devices
|
|
98
|
+
*/
|
|
99
|
+
disconnectAll() {
|
|
100
|
+
for (const device of this.devices.values()) {
|
|
101
|
+
device.networkDevice.disconnect();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get databases() {
|
|
106
|
+
return this._databases;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async downloadFile(ipAddress: string, path: string) {
|
|
110
|
+
const device = this.devices.get(ipAddress);
|
|
111
|
+
const file = await device.fileTransferService.getFile(path);
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
////////////////////////////////////////////////////////////////////////////
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Connect to the player.
|
|
119
|
+
* @param connectionInfo Device to connect to.
|
|
120
|
+
* @returns
|
|
121
|
+
*/
|
|
122
|
+
private async connectToPlayer(connectionInfo: ConnectionInfo) {
|
|
123
|
+
const networkDevice = new NetworkDevice(connectionInfo);
|
|
124
|
+
await networkDevice.connect();
|
|
125
|
+
|
|
126
|
+
Logger.info(`Successfully connected to ${this.deviceId(connectionInfo)}`);
|
|
127
|
+
const fileTransfer = await networkDevice.connectToService(FileTransfer);
|
|
128
|
+
|
|
129
|
+
this.devices.set(connectionInfo.address, {
|
|
130
|
+
networkDevice: networkDevice,
|
|
131
|
+
fileTransferService: fileTransfer
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (this.options.downloadDbSources) {
|
|
135
|
+
const sources = await this.databases.downloadSourcesFromDevice(connectionInfo, networkDevice);
|
|
136
|
+
Logger.debug(`Database sources: ${sources.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Setup StateMap
|
|
140
|
+
const stateMap = await networkDevice.connectToService(StateMap);
|
|
141
|
+
stateMap.on('message', (data) => {
|
|
142
|
+
this.emit('message', connectionInfo, data)
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Setup Player
|
|
146
|
+
const player = new Player({
|
|
147
|
+
stateMap: stateMap,
|
|
148
|
+
address: connectionInfo.address,
|
|
149
|
+
port: connectionInfo.port
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
player.on('trackLoaded', (status) => {
|
|
153
|
+
this.emit('trackLoaded', status);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
player.on('stateChanged', (status) => {
|
|
157
|
+
this.emit('stateChanged', status);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
player.on('nowPlaying', (status) => {
|
|
161
|
+
this.emit('nowPlaying', status);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
this.emit('connected', connectionInfo);
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private deviceId(device: ConnectionInfo) {
|
|
169
|
+
return `${device.address}:${device.port}:` +
|
|
170
|
+
`[${device.source}/${device.software.name}]`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private isConnecting(device: ConnectionInfo) {
|
|
174
|
+
return this.discoveryStatus.get(this.deviceId(device))
|
|
175
|
+
=== ConnectionStatus.CONNECTING;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private isConnected(device: ConnectionInfo) {
|
|
179
|
+
return this.discoveryStatus.get(this.deviceId(device))
|
|
180
|
+
=== ConnectionStatus.CONNECTED;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private isFailed(device: ConnectionInfo) {
|
|
184
|
+
return this.discoveryStatus.get(this.deviceId(device))
|
|
185
|
+
=== ConnectionStatus.FAILED;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private isIgnored(device: ConnectionInfo) {
|
|
189
|
+
return (
|
|
190
|
+
device.source === this.options.actingAs.source
|
|
191
|
+
|| device.software.name === 'OfflineAnalyzer'
|
|
192
|
+
|| /^SoundSwitch/i.test(device.software.name)
|
|
193
|
+
|| /^Resolume/i.test(device.software.name)
|
|
194
|
+
|| device.software.name === 'JM08' // Ignore X1800/X1850 mixers
|
|
195
|
+
|| device.software.name === 'SSS0' // Ignore SoundSwitchEmbedded on players
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private isDeviceSeen(device: ConnectionInfo) {
|
|
200
|
+
return this.discoveryStatus.has(device.address);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private showDiscoveryStatus(device: ConnectionInfo) {
|
|
204
|
+
let msg = `Discovery: ${this.deviceId(device)} `;
|
|
205
|
+
|
|
206
|
+
if (!this.isDeviceSeen) return msg += '(NEW)';
|
|
207
|
+
if (this.isIgnored(device)) return msg += '(IGNORED)';
|
|
208
|
+
return msg += (
|
|
209
|
+
this.isConnecting(device) ? '(CONNECTING)'
|
|
210
|
+
: this.isConnected(device) ? '(CONNECTED)'
|
|
211
|
+
: this.isFailed(device) ? '(FAILED)'
|
|
212
|
+
: '(NEW)');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ConnectionInfo } from '../types';
|
|
2
|
+
import { createSocket, RemoteInfo } from 'dgram';
|
|
3
|
+
import { LISTEN_PORT, DISCOVERY_MESSAGE_MARKER } from '../types/common';
|
|
4
|
+
import { ReadContext } from '../utils/ReadContext';
|
|
5
|
+
import { strict as assert } from 'assert';
|
|
6
|
+
|
|
7
|
+
type DeviceDiscoveryCallback = (info: ConnectionInfo) => void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Continuously listens for devices to announce themselves. When they do,
|
|
11
|
+
* execute a callback.
|
|
12
|
+
*/
|
|
13
|
+
export class StageLinqListener {
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Listen for new devices on the network and callback when a new one is found.
|
|
17
|
+
* @param callback Callback when new device is discovered.
|
|
18
|
+
*/
|
|
19
|
+
listenForDevices(callback: DeviceDiscoveryCallback) {
|
|
20
|
+
const client = createSocket('udp4');
|
|
21
|
+
client.on('message', (p_announcement: Uint8Array, p_remote: RemoteInfo) => {
|
|
22
|
+
const ctx = new ReadContext(p_announcement.buffer, false);
|
|
23
|
+
const result = this.readConnectionInfo(ctx, p_remote.address);
|
|
24
|
+
assert(ctx.tell() === p_remote.size);
|
|
25
|
+
callback(result);
|
|
26
|
+
});
|
|
27
|
+
client.bind(LISTEN_PORT);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private readConnectionInfo(p_ctx: ReadContext, p_address: string): ConnectionInfo {
|
|
31
|
+
const magic = p_ctx.getString(4);
|
|
32
|
+
if (magic !== DISCOVERY_MESSAGE_MARKER) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result: ConnectionInfo = {
|
|
37
|
+
token: p_ctx.read(16),
|
|
38
|
+
source: p_ctx.readNetworkStringUTF16(),
|
|
39
|
+
action: p_ctx.readNetworkStringUTF16(),
|
|
40
|
+
software: {
|
|
41
|
+
name: p_ctx.readNetworkStringUTF16(),
|
|
42
|
+
version: p_ctx.readNetworkStringUTF16(),
|
|
43
|
+
},
|
|
44
|
+
port: p_ctx.readUInt16(),
|
|
45
|
+
address: p_address,
|
|
46
|
+
};
|
|
47
|
+
assert(p_ctx.isEOF());
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANNOUNCEMENT_INTERVAL,
|
|
3
|
+
CONNECT_TIMEOUT,
|
|
4
|
+
DISCOVERY_MESSAGE_MARKER,
|
|
5
|
+
LISTEN_PORT,
|
|
6
|
+
} from '../types';
|
|
7
|
+
import { createSocket, Socket as UDPSocket } from 'dgram';
|
|
8
|
+
import { Logger } from '../LogEmitter';
|
|
9
|
+
import { networkInterfaces } from 'os';
|
|
10
|
+
import { strict as assert } from 'assert';
|
|
11
|
+
import { subnet } from 'ip';
|
|
12
|
+
import { WriteContext } from '../utils/WriteContext';
|
|
13
|
+
import type { DiscoveryMessage } from '../types';
|
|
14
|
+
|
|
15
|
+
function findBroadcastIPs(): string[] {
|
|
16
|
+
const interfaces = Object.values(networkInterfaces());
|
|
17
|
+
assert(interfaces.length);
|
|
18
|
+
const ips = [];
|
|
19
|
+
for (const i of interfaces) {
|
|
20
|
+
assert(i && i.length);
|
|
21
|
+
for (const entry of i) {
|
|
22
|
+
if (entry.family === 'IPv4' && entry.internal === false) {
|
|
23
|
+
const info = subnet(entry.address, entry.netmask);
|
|
24
|
+
ips.push(info.broadcastAddress);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return ips;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
let announceClient: UDPSocket | null = null;
|
|
33
|
+
let announceTimer: NodeJS.Timer | null = null;
|
|
34
|
+
|
|
35
|
+
function writeDiscoveryMessage(p_ctx: WriteContext, p_message: DiscoveryMessage): number {
|
|
36
|
+
let written = 0;
|
|
37
|
+
written += p_ctx.writeFixedSizedString(DISCOVERY_MESSAGE_MARKER);
|
|
38
|
+
written += p_ctx.write(p_message.token);
|
|
39
|
+
written += p_ctx.writeNetworkStringUTF16(p_message.source);
|
|
40
|
+
written += p_ctx.writeNetworkStringUTF16(p_message.action);
|
|
41
|
+
written += p_ctx.writeNetworkStringUTF16(p_message.software.name);
|
|
42
|
+
written += p_ctx.writeNetworkStringUTF16(p_message.software.version);
|
|
43
|
+
written += p_ctx.writeUInt16(p_message.port);
|
|
44
|
+
return written;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function initUdpSocket(): Promise<UDPSocket> {
|
|
48
|
+
return new Promise<UDPSocket>((resolve, reject) => {
|
|
49
|
+
try {
|
|
50
|
+
const client = createSocket('udp4');
|
|
51
|
+
client.bind(); // we need to bind to a random port in order to enable broadcasting
|
|
52
|
+
client.on('listening', () => {
|
|
53
|
+
client.setBroadcast(true); // needs to be true in order to UDP multicast on MacOS
|
|
54
|
+
resolve(client);
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
Logger.error(`Failed to create UDP socket for announcing: ${err}`);
|
|
58
|
+
reject(err);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function broadcastMessage(p_message: Uint8Array): Promise<void> {
|
|
64
|
+
const ips = findBroadcastIPs();
|
|
65
|
+
assert(ips.length > 0, 'No broadcast IPs have been found');
|
|
66
|
+
|
|
67
|
+
const send = async function (p_ip: string): Promise<void> {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
reject(new Error('Failed to send announcement'));
|
|
71
|
+
}, CONNECT_TIMEOUT);
|
|
72
|
+
|
|
73
|
+
announceClient.send(p_message, LISTEN_PORT, p_ip, () => {
|
|
74
|
+
// Logger.log('UDP message sent to ' + p_ip);
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const promises = ips.map((ip) => send(ip));
|
|
81
|
+
await Promise.all(promises);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function unannounce(message: DiscoveryMessage): Promise<void> {
|
|
85
|
+
assert(announceTimer);
|
|
86
|
+
clearInterval(announceTimer);
|
|
87
|
+
announceTimer = null;
|
|
88
|
+
const ctx = new WriteContext();
|
|
89
|
+
writeDiscoveryMessage(ctx, message);
|
|
90
|
+
const msg = new Uint8Array(ctx.getBuffer());
|
|
91
|
+
await broadcastMessage(msg);
|
|
92
|
+
// Logger.info("Unannounced myself");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function announce(message: DiscoveryMessage): Promise<void> {
|
|
96
|
+
if (announceTimer) {
|
|
97
|
+
Logger.log('Already has an announce timer.')
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!announceClient) announceClient = await initUdpSocket();
|
|
102
|
+
|
|
103
|
+
const ctx = new WriteContext();
|
|
104
|
+
writeDiscoveryMessage(ctx, message);
|
|
105
|
+
const msg = new Uint8Array(ctx.getBuffer());
|
|
106
|
+
|
|
107
|
+
// Immediately announce myself
|
|
108
|
+
await broadcastMessage(msg);
|
|
109
|
+
|
|
110
|
+
announceTimer = setInterval(broadcastMessage, ANNOUNCEMENT_INTERVAL, msg);
|
|
111
|
+
Logger.info("Announced myself");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DiscoveryMessageOptions {
|
|
115
|
+
name: string;
|
|
116
|
+
version: string;
|
|
117
|
+
source: string;
|
|
118
|
+
token: Uint8Array;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function createDiscoveryMessage(action: string, discoveryMessageOptions: DiscoveryMessageOptions) {
|
|
122
|
+
const msg: DiscoveryMessage = {
|
|
123
|
+
action: action,
|
|
124
|
+
port: 0,
|
|
125
|
+
software: {
|
|
126
|
+
name: discoveryMessageOptions.name,
|
|
127
|
+
version: discoveryMessageOptions.version
|
|
128
|
+
},
|
|
129
|
+
source: discoveryMessageOptions.source,
|
|
130
|
+
token: discoveryMessageOptions.token
|
|
131
|
+
};
|
|
132
|
+
return msg;
|
|
133
|
+
}
|
package/network/index.ts
ADDED