stagelinq 1.0.4 → 1.0.7

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.
@@ -1,7 +1,7 @@
1
1
  import { ConnectionInfo, Source } from '../types';
2
2
  import { EventEmitter } from 'stream';
3
3
  import { FileTransfer } from '../services';
4
- import { getTempFilePath } from '../albumArt';
4
+ import { getTempFilePath } from '../utils';
5
5
  import { Logger } from '../LogEmitter';
6
6
  import { NetworkDevice } from '../network';
7
7
  import * as fs from 'fs';
@@ -64,6 +64,9 @@ export class Databases extends EventEmitter {
64
64
  }
65
65
 
66
66
  getDbPath(dbSourceName?: string) {
67
+ if (!this.sources.size)
68
+ throw new Error(`No data sources have been downloaded`);
69
+
67
70
  if (!dbSourceName || !this.sources.has(dbSourceName)) {
68
71
 
69
72
  // Hack: Denon will save metadata on streaming files but only on an
@@ -1,4 +1,5 @@
1
1
  import Database = require('better-sqlite3');
2
+ import { Track } from '../types';
2
3
 
3
4
 
4
5
  export class DbConnection {
@@ -31,12 +32,15 @@ export class DbConnection {
31
32
  * @param trackPath Path of track on the source's filesystem.
32
33
  * @returns
33
34
  */
34
- getTrackInfo(trackPath: string) {
35
+ getTrackInfo(trackPath: string): Track {
36
+ let result: Track[];
35
37
  if (/streaming:\/\//.test(trackPath)) {
36
- return this.querySource('SELECT * FROM Track WHERE uri = (?)', trackPath);
38
+ result = this.querySource('SELECT * FROM Track WHERE uri = (?) LIMIT 1', trackPath);
37
39
  } else {
38
- return this.querySource('SELECT * FROM Track WHERE path = (?)', trackPath);
40
+ result = this.querySource('SELECT * FROM Track WHERE path = (?) LIMIT 1', trackPath);
39
41
  }
42
+ if (!result) throw new Error(`Could not find track: ${trackPath} in database.`);
43
+ return result[0];
40
44
  }
41
45
 
42
46
  close() {
package/cli/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ActingAsDevice } from '../types';
1
+ import { ActingAsDevice, PlayerStatus } from '../types';
2
2
  import { DbConnection } from "../Databases";
3
3
  import { sleep } from '../utils/sleep';
4
4
  import { StageLinq } from '../StageLinq';
@@ -10,7 +10,46 @@ require('console-stamp')(console, {
10
10
  format: ':date(HH:MM:ss) :label',
11
11
  });
12
12
 
13
- (async () => {
13
+ /**
14
+ * Get track information for latest playing song.
15
+ *
16
+ * @param stageLinq Instance of StageLinq.
17
+ * @param status Player to get track info from.
18
+ * @returns Track info
19
+ */
20
+ function getTrackInfo(stageLinq: StageLinq, status: PlayerStatus) {
21
+ try {
22
+ const dbPath = stageLinq.databases.getDbPath(status.dbSourceName)
23
+ const connection = new DbConnection(dbPath);
24
+ const result = connection.getTrackInfo(status.trackPath);
25
+ connection.close();
26
+ console.log('Database entry:', result);
27
+ return result;
28
+ } catch(e) {
29
+ console.error(e);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Download the currently playing song from the media.
35
+ *
36
+ * @param stageLinq Instance of StageLinq.
37
+ * @param status Player to download the current song from.
38
+ * @param dest Path to save file to.
39
+ */
40
+ async function downloadFile(stageLinq: StageLinq, status: PlayerStatus, dest: string) {
41
+ try {
42
+ const data = await stageLinq.devices.downloadFile(status.deviceId, status.trackPathAbsolute);
43
+ if (data) {
44
+ fs.writeFileSync(dest, Buffer.from(data));
45
+ console.log(`Downloaded ${status.trackPathAbsolute} to ${dest}`);
46
+ }
47
+ } catch(e) {
48
+ console.error(`Could not download ${status.trackPathAbsolute}`);
49
+ }
50
+ }
51
+
52
+ async function main() {
14
53
 
15
54
  console.log('Starting CLI');
16
55
 
@@ -18,7 +57,7 @@ require('console-stamp')(console, {
18
57
 
19
58
  // If set to true, download the source DBs in a temporary location.
20
59
  // (default: true)
21
- downloadDbSources: true,
60
+ downloadDbSources: false,
22
61
 
23
62
  // Max number of attempts to connect to a StageLinq device.
24
63
  // (default: 3)
@@ -56,20 +95,23 @@ require('console-stamp')(console, {
56
95
  stageLinq.devices.on('connected', async (connectionInfo) => {
57
96
  console.log(`Successfully connected to ${connectionInfo.software.name}`);
58
97
 
59
- // Fires when the database source starts downloading.
60
- stageLinq.databases.on('dbDownloading', (sourceName, dbPath) => {
61
- console.log(`Downloading ${sourceName} to ${dbPath}`);
62
- });
63
-
64
- // Fires while the database source is being read
65
- stageLinq.databases.on('dbProgress', (sourceName, total, bytes, percent) => {
66
- console.debug(`Reading ${sourceName}: ${bytes}/${total} (${Math.ceil(percent)}%)`);
67
- });
98
+ if (stageLinq.options.downloadDbSources) {
99
+ // Fires when the database source starts downloading.
100
+ stageLinq.databases.on('dbDownloading', (sourceName, dbPath) => {
101
+ console.log(`Downloading ${sourceName} to ${dbPath}`);
102
+ });
103
+
104
+ // Fires while the database source is being read
105
+ stageLinq.databases.on('dbProgress', (sourceName, total, bytes, percent) => {
106
+ console.debug(`Reading ${sourceName}: ${bytes}/${total} (${Math.ceil(percent)}%)`);
107
+ });
108
+
109
+ // Fires when the database source has been read and saved to a temporary path.
110
+ stageLinq.databases.on('dbDownloaded', (sourceName, dbPath) => {
111
+ console.log(`Database (${sourceName}) has been downloaded to ${dbPath}`);
112
+ });
113
+ }
68
114
 
69
- // Fires when the database source has been read and saved to a temporary path.
70
- stageLinq.databases.on('dbDownloaded', (sourceName, dbPath) => {
71
- console.log(`Database (${sourceName}) has been downloaded to ${dbPath}`);
72
- });
73
115
  });
74
116
 
75
117
  // Fires when StageLinq and all devices are ready to use.
@@ -83,30 +125,11 @@ require('console-stamp')(console, {
83
125
  // Example of how to connect to the database using this library's
84
126
  // implementation of BetterSqlite3 to get additional information.
85
127
  if (stageLinq.options.downloadDbSources) {
86
- try {
87
- const dbPath = stageLinq.databases.getDbPath(status.dbSourceName)
88
- const connection = new DbConnection(dbPath);
89
- const result = connection.getTrackInfo(status.trackPath);
90
- connection.close();
91
- console.log('Database entry:', result);
92
- } catch(e) {
93
- console.error(e);
94
- }
128
+ getTrackInfo(stageLinq, status);
95
129
  }
96
130
 
97
131
  // Example of how to download the actual track from the media.
98
- try {
99
- const tempfile = path.resolve(os.tmpdir(), 'media');
100
- const data = await stageLinq.devices.downloadFile(status.deviceId, status.trackPathAbsolute);
101
- if (data) {
102
- fs.writeFileSync(tempfile, Buffer.from(data));
103
- console.log(`Downloaded ${status.trackPathAbsolute} to ${tempfile}`);
104
- }
105
- } catch(e) {
106
- console.error(`Could not download ${status.trackPathAbsolute}`);
107
- }
108
-
109
- console.log('New track loaded:', status);
132
+ await downloadFile(stageLinq, status, path.resolve(os.tmpdir(), 'media'));
110
133
  });
111
134
 
112
135
  // Fires when a track has started playing.
@@ -125,9 +148,12 @@ require('console-stamp')(console, {
125
148
 
126
149
  // Fires when the state of a device has changed.
127
150
  stageLinq.devices.on('stateChanged', (status) => {
128
- console.log(`State changed on [${status.deck}]`, status)
151
+ console.log(`Updating state [${status.deck}]`, status)
129
152
  });
130
153
 
154
+ /////////////////////////////////////////////////////////////////////////
155
+ // CLI
156
+
131
157
  let returnCode = 0;
132
158
  try {
133
159
  process.on('SIGINT', async function () {
@@ -156,4 +182,6 @@ require('console-stamp')(console, {
156
182
 
157
183
  await stageLinq.disconnect();
158
184
  process.exit(returnCode);
159
- })();
185
+ }
186
+
187
+ main();
package/devices/Player.ts CHANGED
@@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
2
2
  import { PlayerLayerState, PlayerStatus, ServiceMessage } from '../types';
3
3
  import { PlayerMessageQueue } from './PlayerMessageQueue';
4
4
  import { StateData, StateMap } from '../services';
5
+ import { Logger } from '../LogEmitter';
5
6
 
6
7
  export declare interface Player {
7
8
  on(event: 'trackLoaded', listener: (status: PlayerStatus) => void): this;
@@ -48,6 +49,7 @@ export class Player extends EventEmitter {
48
49
  private masterTempo: number; // Current master tempo BPM
49
50
  private masterStatus: boolean; // If this device has the matser tempo
50
51
  private decks: Map<string, PlayerLayerState> = new Map();
52
+ private lastTrackNetworkPath: Map<string, string> = new Map();
51
53
  private queue: {[layer: string]: PlayerMessageQueue} = {};
52
54
  private deviceId: string;
53
55
 
@@ -132,13 +134,30 @@ export class Player extends EventEmitter {
132
134
  * @param data
133
135
  */
134
136
  private handleUpdate(data: PlayerLayerState) {
137
+ Logger.debug(`data: ${JSON.stringify(data, null, 2)}`);
138
+
135
139
  const layer = data.layer;
136
- const songLoadedSignalPresent = data.hasOwnProperty('songLoaded');
140
+
141
+ // A new song my be loading onto a layer but not yet fully downloaded.
142
+ // For example streaming a song from Beatport Link.
143
+ let isNewTrack = true;
144
+ const lastTrackNetworkPath = this.lastTrackNetworkPath.get(layer);
145
+ if (lastTrackNetworkPath && data.trackNetworkPath) {
146
+ if (data.trackNetworkPath === lastTrackNetworkPath) {
147
+ isNewTrack = false;
148
+ }
149
+ }
150
+ this.lastTrackNetworkPath.set(layer, data.trackNetworkPath);
151
+
152
+ // This will be true once a song has been fully downloaded / loaded.
153
+ const isSongLoaded = data.hasOwnProperty('songLoaded');
137
154
 
138
155
  // If a new song is loaded drop all the previous track data.
139
- if (songLoadedSignalPresent) {
156
+ if (isNewTrack && isSongLoaded) {
157
+ Logger.debug(`Replacing state ${layer}`);
140
158
  this.decks.set(layer, data);
141
159
  } else {
160
+ Logger.debug(`Updating state ${layer}`);
142
161
  this.decks.set(layer, { ...this.decks.get(layer), ...data });
143
162
  }
144
163
 
@@ -172,7 +191,7 @@ export class Player extends EventEmitter {
172
191
  }
173
192
 
174
193
  // If a song is loaded and we have a location emit the trackLoaded event.
175
- if (currentState.trackNetworkPath)
194
+ if (isSongLoaded && currentState.trackNetworkPath)
176
195
  this.emit('trackLoaded', currentState);
177
196
 
178
197
  // If the song is actually playing emit the nowPlaying event.
@@ -1,7 +1,8 @@
1
1
  import { PlayerLayerState } from '../types';
2
2
 
3
3
  // How long to wait for all the messages to come in before firing the callback.
4
- export const UPDATE_RATE_MS = 500;
4
+ // It seems that anything less than 1 second is too fast.
5
+ export const UPDATE_RATE_MS = 1500;
5
6
 
6
7
  export type DataQueueCallback = (data: PlayerLayerState) => void;
7
8
 
@@ -81,13 +81,18 @@ export class NetworkDevice {
81
81
  const ctx = new ReadContext(p_message.buffer, false);
82
82
  while (ctx.isEOF() === false) {
83
83
  const id = ctx.readUInt32();
84
- // FIXME: Verify token
85
- ctx.seek(16); // Skip token; present in all messages
84
+ // const deviceToken = ctx.read(16);
85
+ ctx.seek(16);
86
86
  switch (id) {
87
87
  case MessageId.TimeStamp:
88
- ctx.seek(16); // Skip token; present in all messages
88
+ ctx.seek(16);
89
+ // const secondToken = ctx.read(16); // should be 00..
90
+ // we _shouldn't_ be receiving anything but blank tokens in the 2nd field
91
+ // assert(secondToken.every((x) => x === 0));
92
+
89
93
  // Time Alive is in nanoseconds; convert back to seconds
90
94
  this.timeAlive = Number(ctx.readUInt64() / (1000n * 1000n * 1000n));
95
+ // this.sendTimeStampMsg(deviceToken, Tokens.SoundSwitch);
91
96
  break;
92
97
  case MessageId.ServicesAnnouncement:
93
98
  const service = ctx.readNetworkStringUTF16();
@@ -99,7 +104,6 @@ export class NetworkDevice {
99
104
  break;
100
105
  default:
101
106
  assert.fail(`NetworkDevice Unhandled message id '${id}'`);
102
- break;
103
107
  }
104
108
  }
105
109
  }
@@ -284,4 +288,15 @@ export class NetworkDevice {
284
288
  }
285
289
  });
286
290
  }
291
+
292
+ // private async sendTimeStampMsg(deviceToken: Uint8Array, userToken: Uint8Array, timeAlive?: bigint) {
293
+ // const ctx = new WriteContext();
294
+ // ctx.writeUInt32(MessageId.TimeStamp);
295
+ // ctx.write(deviceToken);
296
+ // ctx.write(userToken);
297
+ // const timeAliveNumber:bigint = (!!timeAlive) ? timeAlive : 0n;
298
+ // ctx.writeUInt64(timeAliveNumber);
299
+ // const written = await this.connection.write(ctx.getBuffer());
300
+ // assert(written === ctx.tell());
301
+ // }
287
302
  }
@@ -29,8 +29,6 @@ export declare interface StageLinqDevices {
29
29
 
30
30
  //////////////////////////////////////////////////////////////////////////////
31
31
 
32
- // TODO: Refactor device, listener, and player into something more nicer.
33
-
34
32
  /**
35
33
  * Handle connecting and disconnecting from discovered devices on the
36
34
  * StageLinq network.
@@ -95,17 +93,39 @@ export class StageLinqDevices extends EventEmitter {
95
93
  /**
96
94
  * Waits for all devices to be connected with databases downloaded
97
95
  * then connects to the StateMap.
96
+ *
97
+ * Explained:
98
+ *
99
+ * Why wait for all devices? Because a race condition exists when using the
100
+ * database methods.
101
+ *
102
+ * If there are two SC6000 players on the network both will be sending
103
+ * broadcast packets and so their StateMap can be initialized at any time
104
+ * in any order.
105
+ *
106
+ * Assume you have player 1 and player 2 linked. Player 2 has a track that
107
+ * is loaded from a USB drive plugged into player 1. Player 2 will be
108
+ * ready before Player 1 because Player 1 will still be downloading a large
109
+ * database. The race condition is if you try to read from the database on
110
+ * the track that is plugged into Player 1 that isn't ready yet.
111
+ *
112
+ * This method prevents that by waiting for both players to connect and
113
+ * have their databases loaded before initializing the StateMap.
114
+ *
98
115
  */
99
116
  private waitForAllDevices() {
100
117
  Logger.log('Start watching for devices ...');
101
118
  this.deviceWatchTimeout = setInterval(async () => {
102
119
  // Check if any devices are still connecting.
103
120
  const values = Array.from(this.discoveryStatus.values());
104
- const foundDevices = values.length > 1;
121
+ const foundDevices = values.length >= 1;
105
122
  const allConnected = !values.includes(ConnectionStatus.CONNECTING);
123
+ const entries = Array.from(this.discoveryStatus.entries());
124
+ Logger.debug(`Waiting devices: ${JSON.stringify(entries)}`);
106
125
 
107
126
  if (foundDevices && allConnected) {
108
127
  Logger.log('All devices found!');
128
+ Logger.debug(`Devices found: ${values.length} ${JSON.stringify(entries)}`);
109
129
  clearInterval(this.deviceWatchTimeout);
110
130
  for (const cb of this.stateMapCallback) {
111
131
  this.setupStateMap(cb.connectionInfo, cb.networkDevice);
@@ -124,15 +144,42 @@ export class StageLinqDevices extends EventEmitter {
124
144
  * @returns
125
145
  */
126
146
  private async connectToDevice(connectionInfo: ConnectionInfo) {
147
+
148
+ // Mark this device as connecting.
127
149
  this.discoveryStatus.set(this.deviceId(connectionInfo), ConnectionStatus.CONNECTING);
150
+
128
151
  let attempt = 1;
129
152
  while (attempt < this.options.maxRetries) {
130
153
  try {
154
+
155
+ // Connect to the device.
131
156
  Logger.info(`Connecting to ${this.deviceId(connectionInfo)}. ` +
132
157
  `Attempt ${attempt}/${this.options.maxRetries}`);
133
- await this.downloadDatabase(connectionInfo);
158
+ const networkDevice = new NetworkDevice(connectionInfo);
159
+ await networkDevice.connect();
160
+
161
+ // Setup file transfer service
162
+ await this.setupFileTransferService(networkDevice, connectionInfo);
163
+
164
+ // Download the database
165
+ if (this.options.downloadDbSources) {
166
+ await this.downloadDatabase(networkDevice, connectionInfo);
167
+ }
168
+
169
+ // Setup other services that should be initialized before StateMap here.
170
+
171
+ // StateMap will be initialized after all devices have completed
172
+ // this method. In other words, StateMap will initialize
173
+ // after all entries in this.discoveryStatus return
174
+ // ConnectionStatus.CONNECTED
175
+
176
+ // Append to the list of states we need to setup later.
177
+ this.stateMapCallback.push({ connectionInfo, networkDevice });
178
+
179
+ // Mark this device as connected.
134
180
  this.discoveryStatus.set(this.deviceId(connectionInfo), ConnectionStatus.CONNECTED);
135
181
  this.emit('connected', connectionInfo);
182
+
136
183
  return; // Don't forget to return!
137
184
  } catch(e) {
138
185
 
@@ -148,32 +195,27 @@ export class StageLinqDevices extends EventEmitter {
148
195
  throw new Error(`Could not connect to ${this.deviceId(connectionInfo)}`);
149
196
  }
150
197
 
151
- /**
152
- * Download databases from the device.
153
- *
154
- * @param connectionInfo Connection info
155
- * @returns
156
- */
157
- private async downloadDatabase(connectionInfo: ConnectionInfo) {
158
- const networkDevice = new NetworkDevice(connectionInfo);
159
- await networkDevice.connect();
160
-
198
+ private async setupFileTransferService(networkDevice: NetworkDevice, connectionInfo: ConnectionInfo) {
161
199
  const sourceId = this.sourceId(connectionInfo);
162
- Logger.info(`Successfully connected to ${this.deviceId(connectionInfo)}`);
200
+ Logger.info(`Starting file transfer for ${this.deviceId(connectionInfo)}`);
163
201
  const fileTransfer = await networkDevice.connectToService(FileTransfer);
164
202
 
165
203
  this.devices.set(`net://${sourceId}`, {
166
204
  networkDevice: networkDevice,
167
205
  fileTransferService: fileTransfer
168
206
  });
207
+ }
169
208
 
170
- if (this.options.downloadDbSources) {
171
- const sources = await this.databases.downloadSourcesFromDevice(connectionInfo, networkDevice);
172
- Logger.debug(`Database sources: ${sources.join(', ')}`);
173
- }
174
-
175
- // Append to the list of states we need to setup later.
176
- this.stateMapCallback.push({ connectionInfo, networkDevice });
209
+ /**
210
+ * Download databases from the device.
211
+ *
212
+ * @param connectionInfo Connection info
213
+ * @returns
214
+ */
215
+ private async downloadDatabase(networkDevice: NetworkDevice, connectionInfo: ConnectionInfo) {
216
+ const sources = await this.databases.downloadSourcesFromDevice(connectionInfo, networkDevice);
217
+ Logger.debug(`Database sources: ${sources.join(', ')}`);
218
+ Logger.debug(`Database download complete for ${connectionInfo.source}`);
177
219
  }
178
220
 
179
221
  private sourceId(connectionInfo: ConnectionInfo) {
@@ -183,6 +225,7 @@ export class StageLinqDevices extends EventEmitter {
183
225
 
184
226
  /**
185
227
  * Setup stateMap.
228
+ *
186
229
  * @param connectionInfo Connection info
187
230
  * @param networkDevice Network device
188
231
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stagelinq",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Typescript library to connect to Denon StageLinq devices",
5
5
  "homepage": "https://github.com/chrisle/StageLinq",
6
6
  "scripts": {
package/types/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { DiscoveryMessageOptions } from '../network';
2
2
 
3
3
  export * from './common';
4
- export * from './database';
5
4
  export * from './player';
6
5
  export * from './tokens';
6
+ export * from './models';
7
7
 
8
8
  export interface DiscoveryMessage {
9
9
  token: Uint8Array;
File without changes
@@ -0,0 +1 @@
1
+ export * from './track';
package/utils/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './Context';
2
+ export * from './getTempFilePath';
2
3
  export * from './hex';
3
4
  export * from './ReadContext';
4
5
  export * from './sleep';
package/albumArt/index.ts DELETED
@@ -1,2 +0,0 @@
1
- export * from '../utils/getTempFilePath';
2
- export * from './maybeDownloadFiles';
@@ -1,34 +0,0 @@
1
- import { NetworkDevice } from '../network';
2
- import { FileTransfer } from '../services';
3
- import { getTempFilePath } from "../utils/getTempFilePath";
4
- import { strict as assert } from 'assert';
5
- import * as fs from 'fs';
6
- import minimist = require('minimist');
7
- import { Logger } from '../LogEmitter';
8
-
9
- export async function maybeDownloadFiles(controller: NetworkDevice) {
10
- const args = minimist(process.argv.slice(2));
11
- if (!args.disableFileTransfer) {
12
- const ftx = await controller.connectToService(FileTransfer);
13
- assert(ftx);
14
- const sources = await ftx.getSources();
15
- {
16
- const sync = !args.skipsync;
17
- for (const source of sources) {
18
- const dbPath = getTempFilePath(source.database.location);
19
- // FIXME: Move all this away from main
20
- if (sync) {
21
- const file = await ftx.getFile(source.database.location);
22
- fs.writeFileSync(dbPath, file);
23
- Logger.info(`downloaded: '${source.database.location}' and stored in '${dbPath}'`);
24
- }
25
- await controller.addSource(source.name, dbPath, getTempFilePath(`${source.name}/Album Art/`));
26
-
27
- if (sync) {
28
- await controller.dumpAlbumArt(source.name);
29
- }
30
- }
31
- ftx.disconnect();
32
- }
33
- }
34
- }