├── .gitignore ├── eslint.config.mjs ├── tests ├── _data │ ├── status-simple.dat │ ├── announce-cdj-2.dat │ └── media-slot-usb.dat ├── tsconfig.json ├── utils │ └── index.spec.ts ├── utils.ts ├── devices │ ├── utils.spec.ts │ └── index.spec.ts ├── status │ └── utils.spec.ts └── remotedb │ └── fields.spec.ts ├── src ├── global.d.ts ├── index.ts ├── nfs │ ├── utils.ts │ ├── rpc.ts │ ├── programs.ts │ ├── index.ts │ └── xdr.ts ├── tsconfig.json ├── remotedb │ ├── constants.ts │ ├── utils.ts │ ├── message │ │ ├── types.ts │ │ ├── index.ts │ │ ├── response.ts │ │ └── item.ts │ ├── fields.ts │ └── index.ts ├── db │ ├── utils.ts │ ├── getWaveforms.ts │ ├── getArtwork.ts │ ├── getMetadata.ts │ ├── getPlaylist.ts │ └── index.ts ├── localdb │ ├── utils.ts │ ├── schema.ts │ ├── orm.ts │ └── index.ts ├── mixstatus │ ├── utils.ts │ └── index.ts ├── devices │ ├── utils.ts │ └── index.ts ├── status │ ├── media.ts │ ├── index.ts │ ├── types.ts │ └── utils.ts ├── constants.ts ├── utils │ ├── converters.ts │ ├── udp.ts │ └── index.ts ├── control │ └── index.ts ├── cli │ └── index.ts ├── entities.ts ├── virtualcdj │ └── index.ts ├── types.ts └── network.ts ├── prettier.config.mjs ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── jest.config.mjs ├── tsconfig.json ├── LICENSE ├── webpack.config.ts ├── package.json ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | docs 3 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import {common} from '@evanpurkhiser/eslint-config'; 2 | 3 | export default [...common]; 4 | -------------------------------------------------------------------------------- /tests/_data/status-simple.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanpurkhiser/prolink-connect/HEAD/tests/_data/status-simple.dat -------------------------------------------------------------------------------- /tests/_data/announce-cdj-2.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanpurkhiser/prolink-connect/HEAD/tests/_data/announce-cdj-2.dat -------------------------------------------------------------------------------- /tests/_data/media-slot-usb.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanpurkhiser/prolink-connect/HEAD/tests/_data/media-slot-usb.dat -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'kaitai-struct' { 2 | export class KaitaiStream { 3 | constructor(data: Buffer); 4 | } 5 | } 6 | declare module 'js-xdr*'; 7 | declare module 'src/localdb/kaitai/*'; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './mixstatus'; 3 | export * from './network'; 4 | 5 | // Types are exported last to avoid overwriting values with type-only exports 6 | export * from './types'; 7 | -------------------------------------------------------------------------------- /src/nfs/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper to flatten linked list structures into an array 3 | */ 4 | export const flattenLinkedList = (item: any): any => [ 5 | item, 6 | ...(item.next() ? flattenLinkedList(item.next()) : []), 7 | ]; 8 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "src/*": ["../src/*"], 7 | "tests/*": ["../tests/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "sourceMap": true, 6 | "outDir": "../lib", 7 | "baseUrl": "./", 8 | "paths": { 9 | "src/*": ["*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | bracketSpacing: false, 3 | jsxBracketSameLine: false, 4 | printWidth: 90, 5 | semi: true, 6 | singleQuote: true, 7 | tabWidth: 2, 8 | trailingComma: 'es5', 9 | useTabs: false, 10 | arrowParens: 'avoid', 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /src/remotedb/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * All remote database messages include this 4 byte magic value. 3 | */ 4 | export const REMOTEDB_MAGIC = 0x872349ae; 5 | 6 | /** 7 | * The consistent port on which we can query the remote db server for the port 8 | */ 9 | export const REMOTEDB_SERVER_QUERY_PORT = 12523; 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | name: build 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: volta-cli/action@v4 11 | - run: yarn install 12 | - run: yarn lint 13 | - run: yarn test 14 | - run: yarn build 15 | -------------------------------------------------------------------------------- /src/db/utils.ts: -------------------------------------------------------------------------------- 1 | import {fetchFile} from 'src/nfs'; 2 | import {Device, MediaSlot} from 'src/types'; 3 | 4 | interface AnlzLoaderOpts { 5 | device: Device; 6 | slot: MediaSlot.RB | MediaSlot.USB | MediaSlot.SD; 7 | } 8 | 9 | export function anlzLoader(opts: AnlzLoaderOpts) { 10 | return (path: string) => fetchFile({...opts, path}); 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['/tests//**/*(*.)@(spec|test).ts'], 5 | moduleNameMapper: { 6 | '^src/(.*)$': '/src/$1', 7 | '^tests/(.*)$': '/tests/$1', 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': ['ts-jest'], 11 | }, 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /tests/utils/index.spec.ts: -------------------------------------------------------------------------------- 1 | import each from 'jest-each'; 2 | 3 | import {bpmToSeconds} from 'src/utils'; 4 | 5 | describe('bpmToSeconds', () => { 6 | each([ 7 | [60, 0, 1], 8 | [120, 0, 0.5], 9 | [60, 25, 0.8], 10 | ]).it( 11 | 'computes [%d bpm at %d pitch] as %d second per beat', 12 | (bpm, pitch, secondsPerBeat) => { 13 | expect(bpmToSeconds(bpm, pitch)).toEqual(secondsPerBeat); 14 | } 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: volta-cli/action@v4 13 | - run: yarn install 14 | - run: yarn test 15 | - run: yarn build 16 | - run: npm set //registry.npmjs.org/:_authToken ${{ secrets.NPM_AUTH_TOKEN }} 17 | - run: npm publish --access=public 18 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import * as ip from 'ip-address'; 4 | 5 | import {readFile} from 'fs/promises'; 6 | 7 | import {Device, DeviceType} from 'src/types'; 8 | 9 | export function readMock(path: string) { 10 | return readFile(`${__dirname}/_data/${path}`); 11 | } 12 | 13 | export function mockDevice(extra?: Partial): Device { 14 | return { 15 | id: 1, 16 | type: DeviceType.CDJ, 17 | name: 'CDJ-test', 18 | ip: new ip.Address4('10.0.0.1'), 19 | macAddr: Uint8Array.of(0x01, 0x02, 0x03, 0x04, 0x05, 0x06), 20 | ...extra, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/localdb/utils.ts: -------------------------------------------------------------------------------- 1 | import {CueAndLoop, HotcueButton} from 'src/types'; 2 | 3 | /** 4 | * Create a CueAndLoop entry given common parameters 5 | */ 6 | export const makeCueLoopEntry = ( 7 | isCue: boolean, 8 | isLoop: boolean, 9 | offset: number, 10 | length: number, 11 | button: false | HotcueButton 12 | ): null | CueAndLoop => 13 | button !== false 14 | ? isLoop 15 | ? {type: 'hot_loop', offset, length, button} 16 | : {type: 'hot_cue', offset, button} 17 | : isLoop 18 | ? {type: 'loop', offset, length} 19 | : isCue 20 | ? {type: 'cue_point', offset} 21 | : null; 22 | -------------------------------------------------------------------------------- /src/mixstatus/utils.ts: -------------------------------------------------------------------------------- 1 | import {CDJStatus} from 'src/types'; 2 | 3 | const playingStates = [CDJStatus.PlayState.Playing, CDJStatus.PlayState.Looping]; 4 | 5 | const stoppingStates = [ 6 | CDJStatus.PlayState.Cued, 7 | CDJStatus.PlayState.Ended, 8 | CDJStatus.PlayState.Loading, 9 | ]; 10 | 11 | /** 12 | * Returns true if the the status reports a playing state. 13 | */ 14 | export const isPlaying = (s: CDJStatus.State) => playingStates.includes(s.playState); 15 | 16 | /** 17 | * Returns true if the status reports a stopping state. 18 | */ 19 | export const isStopping = (s: CDJStatus.State) => stoppingStates.includes(s.playState); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "CommonJS", 5 | "lib": ["ScriptHost", "ES2020"], 6 | "allowJs": true, 7 | "downlevelIteration": true, 8 | "strict": true, 9 | "strictPropertyInitialization": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "sourceMap": true, 15 | "skipLibCheck": true, 16 | "baseUrl": "./", 17 | "paths": {"src/*": ["src/*"]}, 18 | "plugins": [{"transform": "typescript-transform-paths", "afterDeclarations": true}] 19 | }, 20 | "ts-node": { 21 | "transpileOnly": true, 22 | "require": ["typescript-transform-paths/register"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/devices/utils.ts: -------------------------------------------------------------------------------- 1 | import * as ip from 'ip-address'; 2 | 3 | import {PROLINK_HEADER} from 'src/constants'; 4 | import {Device} from 'src/types'; 5 | 6 | /** 7 | * Converts a announce packet to a device object. 8 | */ 9 | export function deviceFromPacket(packet: Buffer) { 10 | if (packet.indexOf(PROLINK_HEADER) !== 0) { 11 | throw new Error('Announce packet does not start with expected header'); 12 | } 13 | 14 | if (packet[0x0a] !== 0x06) { 15 | return null; 16 | } 17 | 18 | const name = packet 19 | .slice(0x0c, 0x0c + 20) 20 | .toString() 21 | .replace(/\0/g, ''); 22 | 23 | const device: Device = { 24 | name, 25 | id: packet[0x24], 26 | type: packet[0x34], 27 | macAddr: new Uint8Array(packet.slice(0x26, 0x26 + 6)), 28 | ip: ip.Address4.fromInteger(packet.readUInt32BE(0x2c)), 29 | }; 30 | 31 | return device; 32 | } 33 | -------------------------------------------------------------------------------- /src/status/media.ts: -------------------------------------------------------------------------------- 1 | import {PROLINK_HEADER} from 'src/constants'; 2 | import {Device, MediaSlot} from 'src/types'; 3 | import {buildName} from 'src/utils'; 4 | 5 | interface Options { 6 | /** 7 | * The device asking for media info 8 | */ 9 | hostDevice: Device; 10 | /** 11 | * The target device. This is the device we'll be querying for details of 12 | * it's media slot. 13 | */ 14 | device: Device; 15 | /** 16 | * The specific slot 17 | */ 18 | slot: MediaSlot; 19 | } 20 | 21 | /** 22 | * Get information about the media connected to the specified slot on the 23 | * device. 24 | */ 25 | export const makeMediaSlotRequest = ({hostDevice, device, slot}: Options) => 26 | Uint8Array.from([ 27 | ...PROLINK_HEADER, 28 | ...[0x05], 29 | ...buildName(hostDevice), 30 | ...[0x01, 0x00], 31 | ...[hostDevice.id], 32 | ...[0x00, 0x0c], 33 | ...hostDevice.ip.toArray(), 34 | ...[0x00, 0x00, 0x00, device.id], 35 | ...[0x00, 0x00, 0x00, slot], 36 | ]); 37 | -------------------------------------------------------------------------------- /tests/devices/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {readMock} from 'tests/utils'; 2 | 3 | import {PROLINK_HEADER} from 'src/constants'; 4 | import {deviceFromPacket} from 'src/devices/utils'; 5 | import {DeviceType} from 'src/types'; 6 | 7 | describe('deviceFromPacket', () => { 8 | it('fails with error for non-prolink packet', () => { 9 | const packet = Buffer.from([]); 10 | 11 | expect(() => deviceFromPacket(packet)).toThrow(); 12 | }); 13 | 14 | it('only handles announce (0x06) packets', () => { 15 | const packet = Buffer.from([...PROLINK_HEADER, 0x05]); 16 | 17 | expect(deviceFromPacket(packet)).toBeNull(); 18 | }); 19 | 20 | it('handles a real announce packet', async () => { 21 | const packet = await readMock('announce-cdj-2.dat'); 22 | 23 | const expected = { 24 | id: 2, 25 | type: DeviceType.CDJ, 26 | name: 'CDJ-2000nexus', 27 | ip: expect.objectContaining({address: '10.0.0.207'}), 28 | macAddr: Uint8Array.of(116, 94, 28, 87, 130, 216), 29 | }; 30 | 31 | expect(deviceFromPacket(packet)).toEqual(expected); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Evan Purkhiser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import nodeExternals from 'webpack-node-externals'; 3 | 4 | import path from 'path'; 5 | 6 | const IS_DEV = process.env.NODE_ENV !== 'production'; 7 | 8 | const config: webpack.Configuration = { 9 | mode: IS_DEV ? 'development' : 'production', 10 | entry: { 11 | index: './src/index.ts', 12 | types: './src/types.ts', 13 | ...(IS_DEV ? {cli: 'src/cli/index'} : {}), 14 | }, 15 | target: 'node', 16 | externals: [nodeExternals() as any], 17 | output: { 18 | path: path.resolve(__dirname, 'lib'), 19 | filename: '[name].js', 20 | libraryTarget: 'commonjs2', 21 | }, 22 | optimization: { 23 | minimize: false, 24 | }, 25 | resolve: { 26 | extensions: ['.ts', '.js'], 27 | alias: {src: path.join(__dirname, 'src')}, 28 | }, 29 | devtool: 'source-map', 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ksy$/, 34 | use: ['kaitai-struct-loader'], 35 | }, 36 | { 37 | test: /\.ts$/, 38 | exclude: /node_modules/, 39 | loader: 'ts-loader', 40 | }, 41 | ], 42 | }, 43 | }; 44 | 45 | export default config; 46 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The default virtual CDJ ID to use. 3 | * 4 | * This particular ID is out of the 1-6 range, thus will not be able to request 5 | * metadata via the remotedb for CDJs. 6 | */ 7 | export const DEFAULT_VCDJ_ID = 0x07; 8 | 9 | /** 10 | * The port on which devices on the prolink network announce themselves. 11 | */ 12 | export const ANNOUNCE_PORT = 50000; 13 | 14 | /** 15 | * The port on which devices on the prolink network send beat timing information. 16 | */ 17 | export const BEAT_PORT = 50001; 18 | 19 | /** 20 | * The port on which devices on the prolink network announce themselves. 21 | */ 22 | export const STATUS_PORT = 50002; 23 | 24 | /** 25 | * The amount of time in ms between sending each announcement packet. 26 | */ 27 | export const ANNOUNCE_INTERVAL = 1500; 28 | 29 | // prettier-ignore 30 | /** 31 | * All UDP packets on the PRO DJ LINK network start with this magic header. 32 | */ 33 | export const PROLINK_HEADER = Uint8Array.of( 34 | 0x51, 0x73, 0x70, 0x74, 0x31, 35 | 0x57, 0x6d, 0x4a, 0x4f, 0x4c 36 | ); 37 | 38 | /** 39 | * VirtualCDJName is the name given to the Virtual CDJ device. 40 | */ 41 | export const VIRTUAL_CDJ_NAME = 'prolink-typescript'; 42 | 43 | /** 44 | * VirtualCDJFirmware is a string indicating the firmware version reported with 45 | * status packets. 46 | */ 47 | export const VIRTUAL_CDJ_FIRMWARE = '1.43'; 48 | -------------------------------------------------------------------------------- /src/utils/converters.ts: -------------------------------------------------------------------------------- 1 | import {WaveformHD} from 'src/types'; 2 | 3 | /** 4 | * Extracts a specific bitmask, shifting it to the bitmask. 5 | */ 6 | export const extractBitMask = (val: number, mask: number): number => 7 | (val & mask) >> Math.log2(mask & -mask); 8 | 9 | /** 10 | * Pioneer colors are 3 bits, convert this to a percentage. 11 | */ 12 | export const extractColor = (val: number, mask: number): number => 13 | extractBitMask(val, mask) / 0b111; 14 | 15 | /** 16 | * Utility to generate an filled with byte offsets for each segment 17 | */ 18 | export const makeOffsetArray = (byteLength: number, segmentSize: number) => 19 | new Array(byteLength / segmentSize).fill(null).map((_, i) => i * segmentSize); 20 | 21 | /** 22 | * Convert raw waveform HD data into the structured WaveformHD type 23 | */ 24 | export const convertWaveformHDData = (data: Buffer): WaveformHD => { 25 | // Two byte bit representation for the color waveform. 26 | // 27 | // | f e d | c b a | 9 8 7 | 6 5 4 3 2 | 1 0 | 28 | // [ red | green | blue | height | ~ | ~ ] 29 | const redMask = 0b11100000_00000000; // prettier-ignore 30 | const greenMask = 0b00011100_00000000; // prettier-ignore 31 | const blueMask = 0b00000011_10000000; // prettier-ignore 32 | const heightMask = 0b00000000_01111100; // prettier-ignore 33 | 34 | const ec = extractColor; 35 | 36 | return makeOffsetArray(data.length, 0x02) 37 | .map(byteOffset => data.readUInt16BE(byteOffset)) 38 | .map(v => ({ 39 | height: extractBitMask(v, heightMask), 40 | color: [ec(v, redMask), ec(v, greenMask), ec(v, blueMask)], 41 | })); 42 | }; 43 | -------------------------------------------------------------------------------- /src/control/index.ts: -------------------------------------------------------------------------------- 1 | import {Socket} from 'dgram'; 2 | 3 | import {BEAT_PORT, PROLINK_HEADER} from 'src/constants'; 4 | import {CDJStatus, Device} from 'src/types'; 5 | import {buildName} from 'src/utils'; 6 | import {udpSend} from 'src/utils/udp'; 7 | 8 | interface Options { 9 | hostDevice: Device; 10 | device: Device; 11 | playState: CDJStatus.PlayState.Cued | CDJStatus.PlayState.Playing; 12 | } 13 | 14 | const STATE_MAP = { 15 | [CDJStatus.PlayState.Cued]: 0x01, 16 | [CDJStatus.PlayState.Playing]: 0x00, 17 | }; 18 | 19 | /** 20 | * Generates the packet used to control the playstate of CDJs 21 | */ 22 | export const makePlaystatePacket = ({hostDevice, device, playState}: Options) => 23 | Uint8Array.from([ 24 | ...PROLINK_HEADER, 25 | ...[0x02], 26 | ...buildName(hostDevice), 27 | ...[0x01, 0x00], 28 | ...[hostDevice.id], 29 | ...[0x00, 0x04], 30 | ...new Array(4) 31 | .fill(0x00) 32 | .map((_, i) => (i === device.id ? STATE_MAP[playState] : 0)), 33 | ]); 34 | 35 | export default class Control { 36 | #hostDevice: Device; 37 | /** 38 | * The socket used to send control packets 39 | */ 40 | #beatSocket: Socket; 41 | 42 | constructor(beatSocket: Socket, hostDevice: Device) { 43 | this.#beatSocket = beatSocket; 44 | this.#hostDevice = hostDevice; 45 | } 46 | 47 | /** 48 | * Start or stop a CDJ on the network 49 | */ 50 | async setPlayState(device: Device, playState: Options['playState']) { 51 | const packet = makePlaystatePacket({hostDevice: this.#hostDevice, device, playState}); 52 | await udpSend(this.#beatSocket, packet, BEAT_PORT, device.ip.address); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/db/getWaveforms.ts: -------------------------------------------------------------------------------- 1 | import {Span} from '@sentry/tracing'; 2 | 3 | import {Track} from 'src/entities'; 4 | import LocalDatabase from 'src/localdb'; 5 | import {loadAnlz} from 'src/localdb/rekordbox'; 6 | import RemoteDatabase, {MenuTarget, Query} from 'src/remotedb'; 7 | import {Device, DeviceID, MediaSlot, TrackType, Waveforms} from 'src/types'; 8 | 9 | import {anlzLoader} from './utils'; 10 | 11 | export interface Options { 12 | /** 13 | * The device to query the track waveforms off of 14 | */ 15 | deviceId: DeviceID; 16 | /** 17 | * The media slot the track is present in 18 | */ 19 | trackSlot: MediaSlot; 20 | /** 21 | * The type of track we are querying waveforms for 22 | */ 23 | trackType: TrackType; 24 | /** 25 | * The track to lookup waveforms for 26 | */ 27 | track: Track; 28 | /** 29 | * The Sentry transaction span 30 | */ 31 | span?: Span; 32 | } 33 | 34 | export async function viaRemote(remote: RemoteDatabase, opts: Required) { 35 | const {deviceId, trackSlot, trackType, track, span} = opts; 36 | 37 | const conn = await remote.get(deviceId); 38 | if (conn === null) { 39 | return null; 40 | } 41 | 42 | const queryDescriptor = { 43 | trackSlot, 44 | trackType, 45 | menuTarget: MenuTarget.Main, 46 | }; 47 | 48 | const waveformHd = await conn.query({ 49 | queryDescriptor, 50 | query: Query.GetWaveformHD, 51 | args: {trackId: track.id}, 52 | span, 53 | }); 54 | 55 | return {waveformHd} as Waveforms; 56 | } 57 | 58 | export async function viaLocal( 59 | local: LocalDatabase, 60 | device: Device, 61 | opts: Required 62 | ) { 63 | const {deviceId, trackSlot, track} = opts; 64 | 65 | if (trackSlot !== MediaSlot.USB && trackSlot !== MediaSlot.SD) { 66 | throw new Error('Expected USB or SD slot for remote database query'); 67 | } 68 | 69 | const conn = await local.get(deviceId, trackSlot); 70 | if (conn === null) { 71 | return null; 72 | } 73 | 74 | const anlz = await loadAnlz(track, 'EXT', anlzLoader({device, slot: trackSlot})); 75 | 76 | return {waveformHd: anlz.waveformHd} as Waveforms; 77 | } 78 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import '@sentry/tracing'; 2 | 3 | import * as Sentry from '@sentry/node'; 4 | import signale from 'signale'; 5 | 6 | import {MixstatusProcessor} from 'src/mixstatus'; 7 | import {bringOnline} from 'src/network'; 8 | 9 | Sentry.init({ 10 | dsn: 'https://36570041fd5a4c05af76456e60a1233a@o126623.ingest.sentry.io/5205486', 11 | tracesSampleRate: 1, 12 | }); 13 | 14 | async function cli() { 15 | signale.await('Bringing up prolink network'); 16 | const network = await bringOnline(); 17 | signale.success('Network online, preparing to connect'); 18 | 19 | network.deviceManager.on('connected', d => 20 | signale.star('New device: %s [id: %s]', d.name, d.id) 21 | ); 22 | 23 | signale.await('Autoconfiguring network.. waiting for devices'); 24 | await network.autoconfigFromPeers(); 25 | signale.await('Autoconfigure successful!'); 26 | 27 | signale.await('Connecting to network!'); 28 | network.connect(); 29 | 30 | if (!network.isConnected()) { 31 | signale.error('Failed to connect to the network'); 32 | return; 33 | } 34 | 35 | signale.star('Network connected! Network services initialized'); 36 | 37 | const processor = new MixstatusProcessor(); 38 | network.statusEmitter.on('status', s => processor.handleState(s)); 39 | 40 | const lastTid = new Map(); 41 | 42 | network.statusEmitter.on('status', async state => { 43 | const {trackDeviceId, trackSlot, trackType, trackId} = state; 44 | 45 | if (lastTid.get(state.deviceId) === trackId) { 46 | return; 47 | } 48 | 49 | lastTid.set(state.deviceId, trackId); 50 | 51 | console.log(trackId); 52 | 53 | const track = await network.db.getMetadata({ 54 | deviceId: trackDeviceId, 55 | trackSlot, 56 | trackType, 57 | trackId, 58 | }); 59 | 60 | if (track === null) { 61 | signale.warn('no track'); 62 | return; 63 | } 64 | 65 | const art = await network.db.getArtwork({ 66 | deviceId: trackDeviceId, 67 | trackSlot, 68 | trackType, 69 | track, 70 | }); 71 | 72 | console.log(trackId, track.title, art?.length); 73 | }); 74 | 75 | await new Promise(r => setTimeout(r, 3000)); 76 | } 77 | 78 | cli(); 79 | -------------------------------------------------------------------------------- /src/db/getArtwork.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {Span} from '@sentry/tracing'; 3 | 4 | import {Track} from 'src/entities'; 5 | import LocalDatabase from 'src/localdb'; 6 | import {fetchFile} from 'src/nfs'; 7 | import RemoteDatabase, {MenuTarget, Query} from 'src/remotedb'; 8 | import {Device, DeviceID, MediaSlot, TrackType} from 'src/types'; 9 | 10 | export interface Options { 11 | /** 12 | * The device to query the track artwork off of 13 | */ 14 | deviceId: DeviceID; 15 | /** 16 | * The media slot the track is present in 17 | */ 18 | trackSlot: MediaSlot; 19 | /** 20 | * The type of track we are querying artwork for 21 | */ 22 | trackType: TrackType; 23 | /** 24 | * The track to lookup artwork for 25 | */ 26 | track: Track; 27 | /** 28 | * The Sentry transaction span 29 | */ 30 | span?: Span; 31 | } 32 | 33 | export async function viaRemote(remote: RemoteDatabase, opts: Required) { 34 | const {deviceId, trackSlot, trackType, track, span} = opts; 35 | 36 | const conn = await remote.get(deviceId); 37 | if (conn === null) { 38 | return null; 39 | } 40 | 41 | if (track.artwork === null) { 42 | return null; 43 | } 44 | 45 | const queryDescriptor = { 46 | trackSlot, 47 | trackType, 48 | menuTarget: MenuTarget.Main, 49 | }; 50 | 51 | return conn.query({ 52 | queryDescriptor, 53 | query: Query.GetArtwork, 54 | args: {artworkId: track.artwork.id}, 55 | span, 56 | }); 57 | } 58 | 59 | export async function viaLocal( 60 | local: LocalDatabase, 61 | device: Device, 62 | opts: Required 63 | ) { 64 | const {deviceId, trackSlot, track} = opts; 65 | 66 | if (trackSlot !== MediaSlot.USB && trackSlot !== MediaSlot.SD) { 67 | throw new Error('Expected USB or SD slot for remote database query'); 68 | } 69 | 70 | const conn = await local.get(deviceId, trackSlot); 71 | if (conn === null) { 72 | return null; 73 | } 74 | 75 | if (track.artwork === null || track.artwork.path === undefined) { 76 | return null; 77 | } 78 | 79 | try { 80 | return fetchFile({device, slot: trackSlot, path: track.artwork.path}); 81 | } catch (error) { 82 | Sentry.captureException(error); 83 | return null; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/udp.ts: -------------------------------------------------------------------------------- 1 | import {BindOptions, Socket} from 'dgram'; 2 | import {AddressInfo} from 'net'; 3 | 4 | /** 5 | * Async version of upd socket bind 6 | */ 7 | export function udpBind( 8 | conn: Socket, 9 | port?: number, 10 | address?: string 11 | ): Promise; 12 | export function udpBind(conn: Socket, options: BindOptions): Promise; 13 | export function udpBind(conn: Socket, arg1?: any, arg2?: any): Promise { 14 | return new Promise((resolve, reject) => { 15 | conn.once('error', reject); 16 | conn.once('listening', () => { 17 | conn.off('error', reject); 18 | resolve(conn.address()); 19 | }); 20 | 21 | if (arg2 !== undefined) { 22 | conn.bind(arg1, arg2); 23 | } else { 24 | conn.bind(arg1); 25 | } 26 | }); 27 | } 28 | 29 | /** 30 | * Async version of udp socket read 31 | */ 32 | export function udpRead(conn: Socket) { 33 | return new Promise(resolve => conn.once('message', resolve)); 34 | } 35 | 36 | /** 37 | * Async version of udp socket send 38 | */ 39 | export function udpSend( 40 | conn: Socket, 41 | msg: Buffer | string | Uint8Array | any[], 42 | port: number, 43 | address: string 44 | ): Promise; 45 | export function udpSend( 46 | conn: Socket, 47 | msg: Buffer | string | Uint8Array, 48 | offset: number, 49 | length: number, 50 | port: number, 51 | address: string 52 | ): Promise; 53 | export function udpSend( 54 | conn: Socket, 55 | arg1: any, 56 | arg2: any, 57 | arg3: any, 58 | arg4?: any, 59 | arg5?: any 60 | ): Promise { 61 | return new Promise((resolve, reject) => { 62 | try { 63 | if (arg4 !== undefined) { 64 | conn.send(arg1, arg2, arg3, arg4, arg5, (err, sent) => 65 | err ? reject(err) : resolve(sent) 66 | ); 67 | } else { 68 | conn.send(arg1, arg2, arg3, (err, sent) => (err ? reject(err) : resolve(sent))); 69 | } 70 | } catch (err) { 71 | reject(err); 72 | } 73 | }); 74 | } 75 | 76 | /** 77 | * Async version of udp socket close 78 | */ 79 | export function udpClose(conn: Socket) { 80 | return new Promise((resolve, reject) => { 81 | try { 82 | conn.once('close', resolve); 83 | conn.close(); 84 | } catch (err) { 85 | reject(err); 86 | } 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as ip from 'ip-address'; 2 | 3 | import {NetworkInterfaceInfo, NetworkInterfaceInfoIPv4, networkInterfaces} from 'os'; 4 | 5 | import {Device, MediaSlot, TrackType} from 'src/types'; 6 | 7 | /** 8 | * Get the byte representation of the device name 9 | */ 10 | export function buildName(device: Device): Uint8Array { 11 | const name = new Uint8Array(20); 12 | name.set(Buffer.from(device.name, 'ascii')); 13 | 14 | return name; 15 | } 16 | 17 | /** 18 | * Determines the interface that routes the given address by comparing the 19 | * masked addresses. This type of information is generally determined through 20 | * the kernels routing table, but for sake of cross-platform compatibility, we 21 | * do some rudimentary lookup. 22 | */ 23 | export function getMatchingInterface(ipAddr: ip.Address4) { 24 | const flatList = Object.entries(networkInterfaces()).reduce( 25 | (acc, [name, info]) => 26 | info !== undefined ? acc.concat(info.map(i => ({...i, name}))) : acc, 27 | [] as Array<{name: string} & NetworkInterfaceInfo> 28 | ); 29 | 30 | let matchedIface: (NetworkInterfaceInfoIPv4 & {name: string}) | null = null; 31 | let matchedSubnet = 0; 32 | 33 | for (const iface of flatList) { 34 | const {internal, cidr} = iface; 35 | 36 | if (iface.family !== 'IPv4' || internal || cidr === null) { 37 | continue; 38 | } 39 | 40 | const ifaceAddr = new ip.Address4(cidr); 41 | 42 | if (ipAddr.isInSubnet(ifaceAddr) && ifaceAddr.subnetMask > matchedSubnet) { 43 | matchedIface = iface; 44 | matchedSubnet = ifaceAddr.subnetMask; 45 | } 46 | } 47 | 48 | return matchedIface; 49 | } 50 | 51 | /** 52 | * Given a BPM and pitch value, compute how many seconds per beat 53 | */ 54 | export function bpmToSeconds(bpm: number, pitch: number) { 55 | const bps = ((pitch / 100) * bpm + bpm) / 60; 56 | return 1 / bps; 57 | } 58 | 59 | const slotNames = Object.fromEntries( 60 | Object.entries(MediaSlot).map(e => [e[1], e[0].toLowerCase()]) 61 | ); 62 | 63 | /** 64 | * Returns a string representation of a media slot 65 | */ 66 | export function getSlotName(slot: MediaSlot) { 67 | return slotNames[slot]; 68 | } 69 | 70 | const trackTypeNames = Object.fromEntries( 71 | Object.entries(TrackType).map(e => [e[1], e[0].toLowerCase()]) 72 | ); 73 | 74 | /** 75 | * Returns a string representation of a track type 76 | */ 77 | export function getTrackTypeName(type: TrackType) { 78 | return trackTypeNames[type]; 79 | } 80 | -------------------------------------------------------------------------------- /tests/status/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {readMock} from 'tests/utils'; 2 | 3 | import {PROLINK_HEADER} from 'src/constants'; 4 | import {PlayState} from 'src/status/types'; 5 | import {mediaSlotFromPacket, statusFromPacket} from 'src/status/utils'; 6 | import {MediaColor, MediaSlot, TrackType} from 'src/types'; 7 | 8 | describe('statusFromPacket', () => { 9 | it('fails with error for non-prolink packet', () => { 10 | const packet = Buffer.from([]); 11 | 12 | expect(() => statusFromPacket(packet)).toThrow(); 13 | }); 14 | 15 | it('only handles announce packets which are large enough', () => { 16 | const packet = Buffer.from([...PROLINK_HEADER, 0x00, 0x00]); 17 | 18 | expect(statusFromPacket(packet)).toBeUndefined(); 19 | }); 20 | 21 | it('handles a real announce packet', async () => { 22 | const packet = await readMock('status-simple.dat'); 23 | 24 | const status = statusFromPacket(packet); 25 | 26 | expect(status).toEqual({ 27 | packetNum: 74108, 28 | deviceId: 3, 29 | beat: null, 30 | beatInMeasure: 0, 31 | beatsUntilCue: null, 32 | effectivePitch: 0, 33 | isMaster: false, 34 | isOnAir: false, 35 | isSync: false, 36 | isEmergencyMode: false, 37 | playState: PlayState.Empty, 38 | sliderPitch: 0, 39 | trackBPM: null, 40 | trackDeviceId: 0, 41 | trackId: 0, 42 | trackSlot: MediaSlot.Empty, 43 | trackType: TrackType.None, 44 | }); 45 | }); 46 | }); 47 | 48 | describe('mediaSlotFromPacket', () => { 49 | it('fails with error for non-prolink packet', () => { 50 | const packet = Buffer.from([]); 51 | 52 | expect(() => mediaSlotFromPacket(packet)).toThrow(); 53 | }); 54 | 55 | it('only handles media slot packet types', () => { 56 | const packet = Buffer.from([...PROLINK_HEADER, 0x05]); 57 | 58 | expect(mediaSlotFromPacket(packet)).toBeUndefined(); 59 | }); 60 | 61 | it('handles a real media slot packet', async () => { 62 | const packet = await readMock('media-slot-usb.dat'); 63 | 64 | const status = mediaSlotFromPacket(packet); 65 | 66 | expect(status).toEqual({ 67 | color: MediaColor.Default, 68 | slot: MediaSlot.USB, 69 | name: '', 70 | deviceId: 2, 71 | createdDate: new Date('2020-10-10T00:00:00.000Z'), 72 | playlistCount: 1, 73 | trackCount: 76, 74 | tracksType: 1, 75 | hasSettings: true, 76 | totalBytes: BigInt('62714675200'), 77 | freeBytes: BigInt('61048520704'), 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prolink-connect", 3 | "version": "0.11.0", 4 | "main": "lib/index.js", 5 | "author": "Evan Purkhiser ", 6 | "keywords": [ 7 | "prolink-network", 8 | "CDJ", 9 | "pioneer", 10 | "DJ", 11 | "reverse-engineer", 12 | "cdj", 13 | "djm" 14 | ], 15 | "license": "MIT", 16 | "scripts": { 17 | "watch": "webpack --watch", 18 | "build": "webpack", 19 | "build-docs": "typedoc --out docs src/index.ts", 20 | "test": "jest", 21 | "lint": "eslint src/**/*.ts tests/**/*.ts", 22 | "preversion": "yarn lint; yarn test", 23 | "prepare": "ts-patch install -s" 24 | }, 25 | "files": [ 26 | "lib/" 27 | ], 28 | "engines": { 29 | "node": ">=20.0.0" 30 | }, 31 | "bin": { 32 | "prolink-connect": "./lib/cli.js" 33 | }, 34 | "sideEffects": false, 35 | "dependencies": { 36 | "@sentry/node": "^6.4.1", 37 | "@sentry/tracing": "^6.4.1", 38 | "@types/better-sqlite3": "^7.6.12", 39 | "@types/lodash": "^4.17.13", 40 | "@types/node": "22.10.2", 41 | "@types/promise-retry": "^1.1.6", 42 | "@types/promise-timeout": "^1.3.3", 43 | "@types/signale": "^1.4.7", 44 | "async-mutex": "^0.3.0", 45 | "better-sqlite3": "^11.7.0", 46 | "ip-address": "^7.0.1", 47 | "js-xdr": "^1.3.0", 48 | "kaitai-struct": "^0.9.0-SNAPSHOT.1", 49 | "lodash": "^4.17.20", 50 | "promise-readable": "^6.0.0", 51 | "promise-retry": "^2.0.1", 52 | "promise-socket": "^7.0.0", 53 | "promise-timeout": "^1.3.0", 54 | "strict-event-emitter-types": "^2.0.0" 55 | }, 56 | "devDependencies": { 57 | "@evanpurkhiser/eslint-config": "^0.25.0", 58 | "@types/jest": "^29.5.14", 59 | "@types/stream-buffers": "^3.0.7", 60 | "@types/webpack": "^5.28.5", 61 | "@types/webpack-node-externals": "^3.0.4", 62 | "eslint": "^9.17.0", 63 | "jest": "^29.7.0", 64 | "jest-each": "^29.2.1", 65 | "kaitai-struct-loader": "^0.9.0", 66 | "loader-utils": "^2.0.0", 67 | "prettier": "^3.4.2", 68 | "signale": "^1.4.0", 69 | "stream-buffers": "^3.0.3", 70 | "ts-jest": "^29.2.5", 71 | "ts-loader": "^9.5.1", 72 | "ts-node": "^10.9.2", 73 | "ts-patch": "^3.3.0", 74 | "typedoc": "^0.27.4", 75 | "typedoc-plugin-missing-exports": "^3.1.0", 76 | "typescript": "^5.7.2", 77 | "typescript-transform-paths": "^3.5.2", 78 | "webpack": "^5.97.1", 79 | "webpack-cli": "^5.1.4", 80 | "webpack-node-externals": "^3.0.0" 81 | }, 82 | "volta": { 83 | "node": "22.11.0", 84 | "yarn": "1.22.22" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/db/getMetadata.ts: -------------------------------------------------------------------------------- 1 | import {Span} from '@sentry/tracing'; 2 | 3 | import LocalDatabase from 'src/localdb'; 4 | import {loadAnlz} from 'src/localdb/rekordbox'; 5 | import RemoteDatabase, {MenuTarget, Query} from 'src/remotedb'; 6 | import {Device, DeviceID, MediaSlot, TrackType} from 'src/types'; 7 | 8 | import {anlzLoader} from './utils'; 9 | 10 | export interface Options { 11 | /** 12 | * The device to query the track metadata from 13 | */ 14 | deviceId: DeviceID; 15 | /** 16 | * The media slot the track is present in 17 | */ 18 | trackSlot: MediaSlot; 19 | /** 20 | * The type of track we are querying for 21 | */ 22 | trackType: TrackType; 23 | /** 24 | * The track id to retrieve metadata for 25 | */ 26 | trackId: number; 27 | /** 28 | * The Sentry transaction span 29 | */ 30 | span?: Span; 31 | } 32 | 33 | export async function viaRemote(remote: RemoteDatabase, opts: Required) { 34 | const {deviceId, trackSlot, trackType, trackId, span} = opts; 35 | 36 | const conn = await remote.get(deviceId); 37 | if (conn === null) { 38 | return null; 39 | } 40 | 41 | const queryDescriptor = { 42 | trackSlot, 43 | trackType, 44 | menuTarget: MenuTarget.Main, 45 | }; 46 | 47 | const track = await conn.query({ 48 | queryDescriptor, 49 | query: Query.GetMetadata, 50 | args: {trackId}, 51 | span, 52 | }); 53 | 54 | track.filePath = await conn.query({ 55 | queryDescriptor, 56 | query: Query.GetTrackInfo, 57 | args: {trackId}, 58 | span, 59 | }); 60 | 61 | track.beatGrid = await conn.query({ 62 | queryDescriptor, 63 | query: Query.GetBeatGrid, 64 | args: {trackId}, 65 | span, 66 | }); 67 | 68 | return track; 69 | } 70 | 71 | export async function viaLocal( 72 | local: LocalDatabase, 73 | device: Device, 74 | opts: Required 75 | ) { 76 | const {deviceId, trackSlot, trackId} = opts; 77 | 78 | if (trackSlot !== MediaSlot.USB && trackSlot !== MediaSlot.SD) { 79 | throw new Error('Expected USB or SD slot for local database query'); 80 | } 81 | 82 | const orm = await local.get(deviceId, trackSlot); 83 | if (orm === null) { 84 | return null; 85 | } 86 | 87 | const track = orm.findTrack(trackId); 88 | 89 | if (track === null) { 90 | return null; 91 | } 92 | 93 | const anlz = await loadAnlz(track, 'DAT', anlzLoader({device, slot: trackSlot})); 94 | 95 | track.beatGrid = anlz.beatGrid; 96 | track.cueAndLoops = anlz.cueAndLoops; 97 | 98 | return track; 99 | } 100 | -------------------------------------------------------------------------------- /src/status/index.ts: -------------------------------------------------------------------------------- 1 | import {Mutex} from 'async-mutex'; 2 | import StrictEventEmitter from 'strict-event-emitter-types'; 3 | 4 | import {Socket} from 'dgram'; 5 | import {EventEmitter} from 'events'; 6 | 7 | import {STATUS_PORT} from 'src/constants'; 8 | import {CDJStatus, MediaSlotInfo} from 'src/types'; 9 | import {udpSend} from 'src/utils/udp'; 10 | 11 | import {makeMediaSlotRequest} from './media'; 12 | import {mediaSlotFromPacket, statusFromPacket} from './utils'; 13 | 14 | interface StatusEvents { 15 | /** 16 | * Fired each time the CDJ reports its status 17 | */ 18 | status: (status: CDJStatus.State) => void; 19 | /** 20 | * Fired when the CDJ reports its media slot status 21 | */ 22 | mediaSlot: (info: MediaSlotInfo) => void; 23 | } 24 | 25 | type Emitter = StrictEventEmitter; 26 | 27 | type MediaSlotOptions = Parameters[0]; 28 | 29 | /** 30 | * The status emitter will report every time a device status is received 31 | */ 32 | class StatusEmitter { 33 | #statusSocket: Socket; 34 | /** 35 | * The EventEmitter which reports the device status 36 | */ 37 | #emitter: Emitter = new EventEmitter(); 38 | /** 39 | * Lock used to avoid media slot query races 40 | */ 41 | #mediaSlotQueryLock = new Mutex(); 42 | 43 | /** 44 | * @param statusSocket A UDP socket to receive CDJ status packets on 45 | */ 46 | constructor(statusSocket: Socket) { 47 | this.#statusSocket = statusSocket; 48 | statusSocket.on('message', this.#handleStatus); 49 | } 50 | 51 | // Bind public event emitter interface 52 | on: Emitter['on'] = this.#emitter.addListener.bind(this.#emitter); 53 | off: Emitter['off'] = this.#emitter.removeListener.bind(this.#emitter); 54 | once: Emitter['once'] = this.#emitter.once.bind(this.#emitter); 55 | 56 | #handleStatus = (message: Buffer) => { 57 | const status = statusFromPacket(message); 58 | 59 | if (status !== undefined) { 60 | return this.#emitter.emit('status', status); 61 | } 62 | 63 | // Media slot status is also reported on this socket 64 | const mediaSlot = mediaSlotFromPacket(message); 65 | 66 | if (mediaSlot !== undefined) { 67 | return this.#emitter.emit('mediaSlot', mediaSlot); 68 | } 69 | 70 | return undefined; 71 | }; 72 | 73 | /** 74 | * Retrieve media slot status information. 75 | */ 76 | async queryMediaSlot(options: MediaSlotOptions) { 77 | const request = makeMediaSlotRequest(options); 78 | 79 | const media = await this.#mediaSlotQueryLock.runExclusive(async () => { 80 | await udpSend(this.#statusSocket, request, STATUS_PORT, options.device.ip.address); 81 | return new Promise(resolve => this.once('mediaSlot', resolve)); 82 | }); 83 | 84 | return media; 85 | } 86 | } 87 | 88 | export default StatusEmitter; 89 | -------------------------------------------------------------------------------- /src/db/getPlaylist.ts: -------------------------------------------------------------------------------- 1 | import {Span} from '@sentry/tracing'; 2 | 3 | import {Playlist} from 'src/entities'; 4 | import LocalDatabase from 'src/localdb'; 5 | import RemoteDatabase, {MenuTarget, Query} from 'src/remotedb'; 6 | import {DeviceID, MediaSlot, PlaylistContents, TrackType} from 'src/types'; 7 | 8 | export interface Options { 9 | /** 10 | * The playlist or folder to query the entries of. This may be left as 11 | * undefined to retrieve the root playlist. 12 | */ 13 | playlist?: Playlist; 14 | /** 15 | * The device to query the track metadata from 16 | */ 17 | deviceId: DeviceID; 18 | /** 19 | * The media slot the track is present in 20 | */ 21 | mediaSlot: MediaSlot; 22 | /** 23 | * The Sentry transaction span 24 | */ 25 | span?: Span; 26 | } 27 | 28 | export async function viaRemote(remote: RemoteDatabase, opts: Options) { 29 | const {playlist, deviceId, mediaSlot, span} = opts; 30 | 31 | const conn = await remote.get(deviceId); 32 | if (conn === null) { 33 | return null; 34 | } 35 | 36 | const queryDescriptor = { 37 | trackSlot: mediaSlot, 38 | trackType: TrackType.RB, 39 | menuTarget: MenuTarget.Main, 40 | }; 41 | 42 | const id = playlist?.id; 43 | const isFolderRequest = playlist?.isFolder ?? true; 44 | 45 | const {folders, playlists, trackEntries} = await conn.query({ 46 | queryDescriptor, 47 | query: Query.MenuPlaylist, 48 | args: {id, isFolderRequest}, 49 | span, 50 | }); 51 | 52 | const iterateTracks = async function* () { 53 | for (const entry of trackEntries) { 54 | if (!conn) { 55 | break; 56 | } 57 | 58 | yield conn.query({ 59 | queryDescriptor, 60 | query: Query.GetMetadata, 61 | args: {trackId: entry.id}, 62 | span, 63 | }); 64 | } 65 | }; 66 | 67 | const tracks = {[Symbol.asyncIterator]: iterateTracks}; 68 | const totalTracks = trackEntries.length; 69 | 70 | return {folders, playlists, tracks, totalTracks} as PlaylistContents; 71 | } 72 | 73 | export async function viaLocal(local: LocalDatabase, opts: Options) { 74 | const {playlist, deviceId, mediaSlot} = opts; 75 | 76 | if (mediaSlot !== MediaSlot.USB && mediaSlot !== MediaSlot.SD) { 77 | throw new Error('Expected USB or SD slot for local database query'); 78 | } 79 | 80 | const orm = await local.get(deviceId, mediaSlot); 81 | if (orm === null) { 82 | return null; 83 | } 84 | 85 | const {folders, playlists, trackEntries} = orm.findPlaylist(playlist?.id); 86 | 87 | const iterateTracks = async function* () { 88 | for (const entry of trackEntries) { 89 | if (!orm) { 90 | break; 91 | } 92 | yield orm.findTrack(entry.id); 93 | } 94 | }; 95 | 96 | const tracks = {[Symbol.asyncIterator]: iterateTracks}; 97 | const totalTracks = trackEntries.length; 98 | 99 | return {folders, playlists, tracks, totalTracks} as PlaylistContents; 100 | } 101 | -------------------------------------------------------------------------------- /src/remotedb/utils.ts: -------------------------------------------------------------------------------- 1 | import {Span} from '@sentry/tracing'; 2 | 3 | import {Items, ItemType} from './message/item'; 4 | import {MessageType} from './message/types'; 5 | import {UInt32} from './fields'; 6 | import {Message} from './message'; 7 | import {Connection, LookupDescriptor} from '.'; 8 | 9 | /** 10 | * Specifies the number of items we should request at a time in menu render 11 | * requests. 12 | */ 13 | const LIMIT = 64; 14 | 15 | export const fieldFromDescriptor = ({ 16 | hostDevice, 17 | menuTarget, 18 | trackSlot, 19 | trackType, 20 | }: LookupDescriptor) => 21 | new UInt32(Buffer.of(hostDevice.id, menuTarget, trackSlot, trackType)); 22 | 23 | export const makeRenderMessage = ( 24 | descriptor: LookupDescriptor, 25 | offset: number, 26 | count: number, 27 | total: number 28 | ) => 29 | new Message({ 30 | type: MessageType.RenderMenu, 31 | args: [ 32 | fieldFromDescriptor(descriptor), 33 | new UInt32(offset), 34 | new UInt32(count), 35 | new UInt32(0), 36 | new UInt32(total), 37 | new UInt32(0x0c), 38 | ], 39 | }); 40 | 41 | /** 42 | * Async generator to page through menu results after a successful lookup 43 | * request. 44 | */ 45 | export async function* renderItems( 46 | conn: Connection, 47 | descriptor: LookupDescriptor, 48 | total: number, 49 | span: Span 50 | ) { 51 | let itemsRead = 0; 52 | 53 | while (itemsRead < total) { 54 | // Request another page of items 55 | if (itemsRead % LIMIT === 0) { 56 | // XXX: itemsRead + count should NOT exceed the total. A larger value 57 | // will push the offset back to accommodate for the extra items, ensuring 58 | // we always receive count items. 59 | const count = Math.min(LIMIT, total - itemsRead); 60 | const message = makeRenderMessage(descriptor, itemsRead, count, total); 61 | 62 | await conn.writeMessage(message, span); 63 | await conn.readMessage(MessageType.MenuHeader, span); 64 | } 65 | 66 | // Read each item. Ignoring headers and footers, we will determine when to 67 | // stop by counting the items read until we reach the total items. 68 | const resp = await conn.readMessage(MessageType.MenuItem, span); 69 | 70 | yield resp.data as Items[T]; 71 | itemsRead++; 72 | 73 | // When we've reached the end of a page we must read the footer 74 | if (itemsRead % LIMIT === 0 || itemsRead === total) { 75 | await conn.readMessage(MessageType.MenuFooter, span); 76 | } 77 | } 78 | } 79 | 80 | const colors = [ 81 | ItemType.ColorNone, 82 | ItemType.ColorPink, 83 | ItemType.ColorRed, 84 | ItemType.ColorOrange, 85 | ItemType.ColorYellow, 86 | ItemType.ColorGreen, 87 | ItemType.ColorAqua, 88 | ItemType.ColorBlue, 89 | ItemType.ColorPurple, 90 | ] as const; 91 | 92 | const colorSet = new Set(colors); 93 | 94 | type ColorType = (typeof colors)[number]; 95 | 96 | /** 97 | * Locate the color item in an item list 98 | */ 99 | export const findColor = (items: Array) => 100 | items.filter(item => colorSet.has(item.type as any)).pop() as Items[ColorType]; 101 | -------------------------------------------------------------------------------- /src/entities.ts: -------------------------------------------------------------------------------- 1 | import {BeatGrid, CueAndLoop, WaveformHD} from 'src/types'; 2 | 3 | /** 4 | * Documentation type strictly for use with entities that have foreign key 5 | * attributes. 6 | */ 7 | export enum EntityFK { 8 | WithFKs, 9 | WithRelations, 10 | } 11 | 12 | export interface Artwork { 13 | id: number; 14 | path?: string; 15 | } 16 | 17 | export interface Key { 18 | id: number; 19 | name: string; 20 | } 21 | 22 | export interface Label { 23 | id: number; 24 | name: string; 25 | } 26 | 27 | export interface Color { 28 | id: number; 29 | name: string; 30 | } 31 | 32 | export interface Genre { 33 | id: number; 34 | name: string; 35 | } 36 | 37 | export interface Album { 38 | id: number; 39 | name: string; 40 | } 41 | 42 | export interface Artist { 43 | id: number; 44 | name: string; 45 | } 46 | 47 | export interface Playlist { 48 | id: number; 49 | name: string; 50 | isFolder: boolean; 51 | parentId: number | null; 52 | } 53 | 54 | interface PlaylistEntryRelations { 55 | track: Track; 56 | } 57 | 58 | interface PlaylistEntryFks { 59 | playlistId: number; 60 | trackId: number; 61 | } 62 | 63 | export type PlaylistEntry = { 64 | id: number; 65 | sortIndex: number; 66 | } & (withFKs extends EntityFK.WithFKs ? PlaylistEntryFks : PlaylistEntryRelations); 67 | 68 | interface TrackRelations { 69 | artwork: Artwork | null; 70 | artist: Artist | null; 71 | originalArtist: Artist | null; 72 | remixer: Artist | null; 73 | composer: Artist | null; 74 | album: Album | null; 75 | label: Label | null; 76 | genre: Genre | null; 77 | color: Color | null; 78 | key: Key | null; 79 | } 80 | 81 | interface TrackFks { 82 | artworkId?: number; 83 | artistId?: number; 84 | originalArtistId?: number; 85 | remixerId?: number; 86 | composerId?: number; 87 | albumId?: number; 88 | labelId?: number; 89 | genreId?: number; 90 | colorId?: number; 91 | keyId?: number; 92 | } 93 | 94 | /** 95 | * Represents a track. 96 | * 97 | * Note, fields that are not optional will be set for all database request 98 | * methods. 99 | */ 100 | export type Track = { 101 | id: number; 102 | title: string; 103 | duration: number; 104 | bitrate?: number; 105 | tempo: number; 106 | rating: number; 107 | comment: string; 108 | filePath: string; 109 | fileName: string; 110 | trackNumber?: number; 111 | discNumber?: number; 112 | sampleRate?: number; 113 | sampleDepth?: number; 114 | playCount?: number; 115 | year?: number; 116 | mixName?: string; 117 | autoloadHotcues?: boolean; 118 | kuvoPublic?: boolean; 119 | fileSize?: number; 120 | analyzePath?: string; 121 | releaseDate?: string; 122 | analyzeDate?: Date; 123 | dateAdded?: Date; 124 | 125 | /** 126 | * Embedded beat grid information 127 | */ 128 | beatGrid: BeatGrid | null; 129 | 130 | /** 131 | * Embedded cue and loop information 132 | */ 133 | cueAndLoops: CueAndLoop[] | null; 134 | 135 | /** 136 | * Embedded HD Waveform information 137 | */ 138 | waveformHd: WaveformHD | null; 139 | } & (withFKs extends EntityFK.WithFKs ? TrackFks : TrackRelations); 140 | -------------------------------------------------------------------------------- /src/status/types.ts: -------------------------------------------------------------------------------- 1 | import {DeviceID, MediaSlot, TrackType} from 'src/types'; 2 | 3 | /** 4 | * Status flag bitmasks 5 | */ 6 | export enum StatusFlag { 7 | OnAir = 1 << 3, 8 | Sync = 1 << 4, 9 | Master = 1 << 5, 10 | Playing = 1 << 6, 11 | } 12 | 13 | /** 14 | * Play state flags 15 | */ 16 | export enum PlayState { 17 | Empty = 0x00, 18 | Loading = 0x02, 19 | Playing = 0x03, 20 | Looping = 0x04, 21 | Paused = 0x05, 22 | Cued = 0x06, 23 | Cuing = 0x07, 24 | PlatterHeld = 0x08, 25 | Searching = 0x09, 26 | SpunDown = 0x0e, 27 | Ended = 0x11, 28 | } 29 | 30 | /** 31 | * Represents various details about the current state of the CDJ. 32 | */ 33 | export interface State { 34 | /** 35 | * The device reporting this status. 36 | */ 37 | deviceId: number; 38 | /** 39 | * The ID of the track loaded on the device. 40 | * 41 | * 0 When no track is loaded. 42 | */ 43 | trackId: number; 44 | /** 45 | * The device ID the track is loaded from. 46 | * 47 | * For example if you have two CDJs and you've loaded a track over the 'LINK', 48 | * this will be the ID of the player with the USB media device connected to it. 49 | */ 50 | trackDeviceId: DeviceID; 51 | /** 52 | * The MediaSlot the track is loaded from. For example a SD card or USB device. 53 | */ 54 | trackSlot: MediaSlot; 55 | /** 56 | * The TrackType of the track, for example a CD or Rekordbox analyzed track. 57 | */ 58 | trackType: TrackType; 59 | /** 60 | * The current play state of the CDJ. 61 | */ 62 | playState: PlayState; 63 | /** 64 | * Whether the CDJ is currently reporting itself as 'on-air'. 65 | * 66 | * This is indicated by the red ring around the platter on the CDJ Nexus models. 67 | * A DJM mixer must be ont he network for the CDJ to report this as true. 68 | */ 69 | isOnAir: boolean; 70 | /** 71 | * Whether the CDJ is synced. 72 | */ 73 | isSync: boolean; 74 | /** 75 | * Whether the CDJ is the master player. 76 | */ 77 | isMaster: boolean; 78 | /** 79 | * Whether the CDJ is in an emergency state (emergecy loop / emergency mode 80 | * on newer players) 81 | */ 82 | isEmergencyMode: boolean; 83 | /** 84 | * The BPM of the loaded track. null if no track is loaded or the BPM is unknown. 85 | */ 86 | trackBPM: number | null; 87 | /** 88 | * The "effective" pitch of the plyaer. This is reported anytime the jogwheel is 89 | * nudged, the CDJ spins down by pausing with the vinyl stop knob not at 0, or 90 | * by holding the platter. 91 | */ 92 | effectivePitch: number; 93 | /** 94 | * The current slider pitch 95 | */ 96 | sliderPitch: number; 97 | /** 98 | * The current beat within the measure. 1-4. 0 when no track is loaded. 99 | */ 100 | beatInMeasure: number; 101 | /** 102 | * Number of beats remaining until the next cue point is reached. Null if there 103 | * is no next cue point 104 | */ 105 | beatsUntilCue: number | null; 106 | /** 107 | * The beat 'timestamp' of the track. Can be used to compute absolute track time 108 | * given the slider pitch. 109 | */ 110 | beat: number | null; 111 | /** 112 | * A counter that increments for every status packet sent. 113 | */ 114 | packetNum: number; 115 | } 116 | -------------------------------------------------------------------------------- /src/remotedb/message/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Used for control messages with the remote database 3 | */ 4 | export enum ControlRequest { 5 | Introduce = 0x0000, 6 | Disconnect = 0x0100, 7 | RenderMenu = 0x3000, 8 | } 9 | 10 | /** 11 | * Used to setup renders for specific Menus 12 | */ 13 | export enum MenuRequest { 14 | MenuRoot = 0x1000, 15 | MenuGenre = 0x1001, 16 | MenuArtist = 0x1002, 17 | MenuAlbum = 0x1003, 18 | MenuTrack = 0x1004, 19 | MenuBPM = 0x1006, 20 | MenuRating = 0x1007, 21 | MenuYear = 0x1008, 22 | MenuLabel = 0x100a, 23 | MenuColor = 0x100d, 24 | MenuTime = 0x1010, 25 | MenuBitrate = 0x1011, 26 | MenuHistory = 0x1012, 27 | MenuFilename = 0x1013, 28 | MenuKey = 0x1014, 29 | MenuOriginalArtist = 0x1302, 30 | MenuRemixer = 0x1602, 31 | MenuPlaylist = 0x1105, 32 | MenuArtistsOfGenre = 0x1101, 33 | MenuAlbumsOfArtist = 0x1102, 34 | MenuTracksOfAlbum = 0x1103, 35 | MenuTracksOfRating = 0x1107, 36 | MenuYearsOfDecade = 0x1108, 37 | MenuArtistsOfLabel = 0x110a, 38 | MenuTracksOfColor = 0x110d, 39 | MenuTracksOfTime = 0x1110, 40 | MenuTracksOfHistory = 0x1112, 41 | MenuDistancesOfKey = 0x1114, 42 | MenuAlbumsOfOriginalArtist = 0x1402, 43 | MenuAlbumsOfRemixer = 0x1702, 44 | MenuAlbumsOfGenreAndArtist = 0x1201, 45 | MenuTracksOfArtistAndAlbum = 0x1202, 46 | MenuTracksOfBPMPercentRange = 0x1206, 47 | MenuTracksOfDecadeAndYear = 0x1208, 48 | MenuAlbumsOfLabelAndArtist = 0x120a, 49 | MenuTracksNearKey = 0x1214, 50 | MenuTracksOfOriginalArtistAndAlbum = 0x1502, 51 | MenuTracksOfRemixerAndAlbum = 0x1802, 52 | MenuTracksOfGenreArtistAndAlbum = 0x1301, 53 | MenuTracksOfLabelArtistAndAlbum = 0x130a, 54 | MenuSearch = 0x1300, 55 | MenuFolder = 0x2006, 56 | } 57 | 58 | /** 59 | * Request message types used to obtain specfiic track information 60 | */ 61 | export enum DataRequest { 62 | GetMetadata = 0x2002, 63 | GetArtwork = 0x2003, 64 | GetWaveformPreview = 0x2004, 65 | GetTrackInfo = 0x2102, 66 | GetGenericMetadata = 0x2202, 67 | GetCueAndLoops = 0x2104, 68 | GetBeatGrid = 0x2204, 69 | GetWaveformDetailed = 0x2904, 70 | GetAdvCueAndLoops = 0x2b04, 71 | GetWaveformHD = 0x2c04, 72 | } 73 | 74 | /** 75 | * Response message types for messages sent back by the server. 76 | */ 77 | export enum Response { 78 | Success = 0x4000, 79 | Error = 0x4003, 80 | Artwork = 0x4002, 81 | MenuItem = 0x4101, 82 | MenuHeader = 0x4001, 83 | MenuFooter = 0x4201, 84 | BeatGrid = 0x4602, 85 | CueAndLoop = 0x4702, 86 | WaveformPreview = 0x4402, 87 | WaveformDetailed = 0x4a02, 88 | AdvCueAndLoops = 0x4e02, 89 | WaveformHD = 0x4f02, 90 | } 91 | 92 | /** 93 | * Request message types, only sent to the device. 94 | */ 95 | export type Request = ControlRequest | MenuRequest | DataRequest; 96 | 97 | export const Request = { 98 | ...ControlRequest, 99 | ...MenuRequest, 100 | ...DataRequest, 101 | } as const; 102 | 103 | /** 104 | * All Known message types. These are used for both request and response messages. 105 | */ 106 | export type MessageType = ControlRequest | MenuRequest | DataRequest | Response; 107 | 108 | export const MessageType = { 109 | ...ControlRequest, 110 | ...MenuRequest, 111 | ...DataRequest, 112 | ...Response, 113 | } as const; 114 | 115 | const MessageTypeInverse = Object.fromEntries( 116 | Object.entries(MessageType).map(e => [e[1], e[0]]) 117 | ); 118 | 119 | /** 120 | * Returns a string representation of a message type 121 | */ 122 | export function getMessageName(type: MessageType) { 123 | return MessageTypeInverse[type]; 124 | } 125 | -------------------------------------------------------------------------------- /src/status/utils.ts: -------------------------------------------------------------------------------- 1 | import {PROLINK_HEADER} from 'src/constants'; 2 | import {CDJStatus, MediaSlotInfo} from 'src/types'; 3 | 4 | const MAX_INT32 = Math.pow(2, 32) - 1; 5 | const MAX_INT16 = Math.pow(2, 16) - 1; 6 | const MAX_INT9 = Math.pow(2, 9) - 1; 7 | 8 | export function statusFromPacket(packet: Buffer) { 9 | if (packet.indexOf(PROLINK_HEADER) !== 0) { 10 | throw new Error('CDJ status packet does not start with the expected header'); 11 | } 12 | 13 | // Rekordbox sends some short status packets that we can just ignore. 14 | if (packet.length < 0xc8) { 15 | return undefined; 16 | } 17 | 18 | // No track loaded: BPM = MAX_INT16 19 | const rawBPM = packet.readUInt16BE(0x92); 20 | const trackBPM = rawBPM === MAX_INT16 ? null : rawBPM / 100; 21 | 22 | // No next cue: beatsUntilCue = MAX_INT9 23 | const rawBeatsUntilCue = packet.readUInt16BE(0xa4); 24 | const beatsUntilCue = rawBeatsUntilCue === MAX_INT9 ? null : rawBeatsUntilCue; 25 | 26 | // No track loaded: beat = MAX_INT32 27 | const rawBeat = packet.readUInt32BE(0xa0); 28 | const beat = rawBeat === MAX_INT32 ? null : rawBeat; 29 | 30 | const status: CDJStatus.State = { 31 | deviceId: packet[0x21], 32 | trackId: packet.readUInt32BE(0x2c), 33 | trackDeviceId: packet[0x28], 34 | trackSlot: packet[0x29], 35 | trackType: packet[0x2a], 36 | playState: packet[0x7b], 37 | isOnAir: (packet[0x89] & CDJStatus.StatusFlag.OnAir) !== 0, 38 | isSync: (packet[0x89] & CDJStatus.StatusFlag.Sync) !== 0, 39 | isMaster: (packet[0x89] & CDJStatus.StatusFlag.Master) !== 0, 40 | isEmergencyMode: !!packet[0xba], 41 | trackBPM, 42 | sliderPitch: calcPitch(packet.slice(0x8d, 0x8d + 3)), 43 | effectivePitch: calcPitch(packet.slice(0x99, 0x99 + 3)), 44 | beatInMeasure: packet[0xa6], 45 | beatsUntilCue, 46 | beat, 47 | packetNum: packet.readUInt32BE(0xc8), 48 | }; 49 | 50 | return status; 51 | } 52 | 53 | export function mediaSlotFromPacket(packet: Buffer) { 54 | if (packet.indexOf(PROLINK_HEADER) !== 0) { 55 | throw new Error('CDJ media slot packet does not start with the expected header'); 56 | } 57 | 58 | if (packet[0x0a] !== 0x06) { 59 | return undefined; 60 | } 61 | 62 | const name = packet 63 | .slice(0x2c, 0x0c + 40) 64 | .toString() 65 | .replace(/\0/g, ''); 66 | 67 | const createdDate = new Date( 68 | packet 69 | .slice(0x6c, 0x6c + 24) 70 | .toString() 71 | .replace(/\0/g, '') 72 | ); 73 | 74 | const deviceId = packet[0x27]; 75 | const slot = packet[0x2b]; 76 | 77 | const trackCount = packet.readUInt16BE(0xa6); 78 | const tracksType = packet[0xaa]; 79 | const hasSettings = !!packet[0xab]; 80 | const playlistCount = packet.readUInt16BE(0xae); 81 | const color = packet.readUInt8(0xa8); 82 | const totalBytes = packet.readBigUInt64BE(0xb0); 83 | const freeBytes = packet.readBigUInt64BE(0xb8); 84 | 85 | const info: MediaSlotInfo = { 86 | deviceId, 87 | slot, 88 | name, 89 | color, 90 | createdDate, 91 | freeBytes, 92 | totalBytes, 93 | tracksType, 94 | trackCount, 95 | playlistCount, 96 | hasSettings, 97 | }; 98 | 99 | return info; 100 | } 101 | 102 | /** 103 | * calcPitch converts a uint24 byte value into a float32 pitch. 104 | * 105 | * The pitch information ranges from 0x000000 (meaning -100%, complete stop) to 106 | * 0x200000 (+100%). 107 | */ 108 | function calcPitch(pitch: Buffer) { 109 | const value = Buffer.from([0x0, ...pitch]).readUInt32BE(); 110 | const relativeZero = 0x100000; 111 | 112 | const computed = ((value - relativeZero) / relativeZero) * 100; 113 | 114 | return +computed.toFixed(2); 115 | } 116 | -------------------------------------------------------------------------------- /src/localdb/schema.ts: -------------------------------------------------------------------------------- 1 | import {Table} from './orm'; 2 | 3 | export const generateSchema = () => ` 4 | CREATE TABLE '${Table.Artist}' ( 5 | 'id' integer not null primary key, 6 | 'name' varchar not null 7 | ); 8 | CREATE TABLE '${Table.Album}' ( 9 | 'id' integer not null primary key, 10 | 'name' varchar not null 11 | ); 12 | CREATE TABLE '${Table.Genre}' ( 13 | 'id' integer not null primary key, 14 | 'name' varchar not null 15 | ); 16 | CREATE TABLE '${Table.Color}' ( 17 | 'id' integer not null primary key, 18 | 'name' varchar not null 19 | ); 20 | CREATE TABLE '${Table.Label}' ( 21 | 'id' integer not null primary key, 22 | 'name' varchar not null 23 | ); 24 | CREATE TABLE '${Table.Key}' ( 25 | 'id' integer not null primary key, 26 | 'name' varchar not null 27 | ); 28 | CREATE TABLE '${Table.Artwork}' ( 29 | 'id' integer not null primary key, 30 | 'path' varchar not null 31 | ); 32 | CREATE TABLE '${Table.Track}' ( 33 | 'id' integer not null primary key, 34 | 'title' varchar not null, 35 | 'duration' integer not null, 36 | 'bitrate' integer not null, 37 | 'tempo' integer not null, 38 | 'rating' integer not null, 39 | 'comment' varchar not null, 40 | 'file_path' varchar not null, 41 | 'file_name' varchar not null, 42 | 'track_number' integer not null, 43 | 'disc_number' integer not null, 44 | 'sample_rate' integer not null, 45 | 'sample_depth' integer not null, 46 | 'play_count' integer not null, 47 | 'year' integer not null, 48 | 'mix_name' varchar not null, 49 | 'autoload_hotcues' integer not null, 50 | 'kuvo_public' integer not null, 51 | 'file_size' integer not null, 52 | 'analyze_path' varchar not null, 53 | 'release_date' varchar not null, 54 | 'analyze_date' datetime, 55 | 'date_added' datetime, 56 | 'beat_grid' text null, 57 | 'cue_and_loops' text null, 58 | 'waveform_hd' text null, 59 | 'artwork_id' integer null, 60 | 'artist_id' integer null, 61 | 'original_artist_id' integer null, 62 | 'remixer_id' integer null, 63 | 'composer_id' integer null, 64 | 'album_id' integer null, 65 | 'label_id' integer null, 66 | 'genre_id' integer null, 67 | 'color_id' integer null, 68 | 'key_id' integer null 69 | ); 70 | CREATE TABLE '${Table.Playlist}' ( 71 | 'id' integer not null primary key, 72 | 'is_folder' integer not null, 73 | 'name' varchar not null, 74 | 'parent_id' integer null 75 | ); 76 | CREATE TABLE '${Table.PlaylistEntry}' ( 77 | 'id' integer not null primary key, 78 | 'sort_index' integer not null, 79 | 'playlist_id' integer null, 80 | 'track_id' integer null 81 | ); 82 | CREATE INDEX 'track_artwork_id_index' on '${Table.Track}' ('artwork_id'); 83 | CREATE INDEX 'track_artist_id_index' on '${Table.Track}' ('artist_id'); 84 | CREATE INDEX 'track_original_artist_id_index' on '${Table.Track}' ('original_artist_id'); 85 | CREATE INDEX 'track_remixer_id_index' on '${Table.Track}' ('remixer_id'); 86 | CREATE INDEX 'track_composer_id_index' on '${Table.Track}' ('composer_id'); 87 | CREATE INDEX 'track_album_id_index' on '${Table.Track}' ('album_id'); 88 | CREATE INDEX 'track_label_id_index' on '${Table.Track}' ('label_id'); 89 | CREATE INDEX 'track_genre_id_index' on '${Table.Track}' ('genre_id'); 90 | CREATE INDEX 'track_color_id_index' on '${Table.Track}' ('color_id'); 91 | CREATE INDEX 'track_key_id_index' on '${Table.Track}' ('key_id'); 92 | CREATE INDEX 'playlist_parent_id_index' on '${Table.Playlist}' ('parent_id'); 93 | CREATE INDEX 'playlist_entry_playlist_id_index' on '${Table.PlaylistEntry}' ('playlist_id'); 94 | CREATE INDEX 'playlist_entry_track_id_index' on '${Table.PlaylistEntry}' ('track_id'); 95 | `; 96 | -------------------------------------------------------------------------------- /tests/remotedb/fields.spec.ts: -------------------------------------------------------------------------------- 1 | import PromiseReadable from 'promise-readable'; 2 | import {ReadableStreamBuffer} from 'stream-buffers'; 3 | 4 | import * as Field from 'src/remotedb/fields'; 5 | 6 | describe('UInt8', () => { 7 | let num: Field.NumberField; 8 | 9 | afterEach(() => { 10 | expect(num.value).toBe(5); 11 | expect(num.data).toHaveLength(1); 12 | expect(num.data[0]).toBe(0x05); 13 | expect(num.buffer).toHaveLength(2); 14 | expect(num.buffer[0]).toBe(Field.FieldType.UInt8); 15 | }); 16 | 17 | it('encodes', () => { 18 | num = new Field.UInt8(5); 19 | }); 20 | 21 | it('decodes', () => { 22 | num = new Field.UInt8(Buffer.of(0x05)); 23 | }); 24 | }); 25 | 26 | describe('UInt16', () => { 27 | let num: Field.NumberField; 28 | 29 | afterEach(() => { 30 | expect(num.value).toBe(5); 31 | expect(num.data).toHaveLength(2); 32 | expect([...num.data]).toEqual([0x00, 0x05]); 33 | expect(num.buffer).toHaveLength(3); 34 | expect(num.buffer[0]).toBe(Field.FieldType.UInt16); 35 | }); 36 | 37 | it('encodes', () => { 38 | num = new Field.UInt16(5); 39 | }); 40 | 41 | it('decodes', () => { 42 | num = new Field.UInt16(Buffer.of(0x00, 0x05)); 43 | }); 44 | }); 45 | 46 | describe('UInt32', () => { 47 | let num: Field.NumberField; 48 | 49 | afterEach(() => { 50 | expect(num.value).toBe(5); 51 | expect(num.data).toHaveLength(4); 52 | expect([...num.data]).toEqual([0x00, 0x00, 0x00, 0x05]); 53 | expect(num.buffer).toHaveLength(5); 54 | expect(num.buffer[0]).toBe(Field.FieldType.UInt32); 55 | }); 56 | 57 | it('encodes', () => { 58 | num = new Field.UInt32(5); 59 | }); 60 | 61 | it('decodes', () => { 62 | num = new Field.UInt32(Buffer.of(0x00, 0x00, 0x00, 0x05)); 63 | }); 64 | }); 65 | 66 | describe('String', () => { 67 | let string: Field.StringField; 68 | 69 | afterEach(() => { 70 | expect(string.value).toBe('test'); 71 | expect(string.data).toHaveLength(10); 72 | // prettier-ignore 73 | expect([...string.data]).toEqual([0x00, 0x74, 0x00, 0x65, 0x00, 0x73, 0x00, 0x74, 0x00, 0x00]); 74 | expect(string.buffer).toHaveLength(15); 75 | expect(string.buffer[0]).toBe(Field.FieldType.String); 76 | }); 77 | 78 | it('encodes', () => { 79 | string = new Field.String('test'); 80 | }); 81 | 82 | it('decodes', () => { 83 | string = new Field.String( 84 | Buffer.of(0x00, 0x74, 0x00, 0x65, 0x00, 0x73, 0x00, 0x74, 0x00, 0x00) 85 | ); 86 | }); 87 | }); 88 | 89 | describe('Binary', () => { 90 | let string: Field.BinaryField; 91 | 92 | afterEach(() => { 93 | expect([...string.value]).toEqual([0x0a, 0x0b, 0x0c]); 94 | expect(string.data).toHaveLength(3); 95 | expect([...string.data]).toEqual([0x0a, 0x0b, 0x0c]); 96 | expect(string.buffer).toHaveLength(8); 97 | expect(string.buffer[0]).toBe(Field.FieldType.Binary); 98 | }); 99 | 100 | it('encodes and decodes', () => { 101 | string = new Field.Binary(Buffer.of(0x0a, 0x0b, 0x0c)); 102 | }); 103 | }); 104 | 105 | describe('readField', () => { 106 | const streamBuffer = new ReadableStreamBuffer(); 107 | const socket = new PromiseReadable(streamBuffer); 108 | 109 | it('raises an error when the wrong field is read', async () => { 110 | streamBuffer.put(Buffer.of(Field.FieldType.UInt16)); 111 | 112 | await expect(async () => { 113 | await Field.readField(socket, Field.FieldType.UInt8); 114 | }).rejects.toThrow('Expected UInt8 but got UInt16'); 115 | }); 116 | 117 | it('reads a fixed size integer', async () => { 118 | streamBuffer.put(Buffer.of(Field.FieldType.UInt8, 0x05)); 119 | const data = await Field.readField(socket, Field.FieldType.UInt8); 120 | 121 | expect(data).toBeInstanceOf(Field.UInt8); 122 | expect(data.value).toBe(0x05); 123 | }); 124 | 125 | it('reads a variable sized binary field', async () => { 126 | streamBuffer.put( 127 | Buffer.of(Field.FieldType.Binary, 0x00, 0x00, 0x00, 0x02, 0x01, 0x02) 128 | ); 129 | const data = await Field.readField(socket, Field.FieldType.Binary); 130 | 131 | expect(data).toBeInstanceOf(Field.Binary); 132 | expect(data.value).toBeInstanceOf(Buffer); 133 | expect(data.value).toEqual(Buffer.of(0x01, 0x02)); 134 | }); 135 | 136 | it('does not read 0 length data of a empty binary field', async () => { 137 | streamBuffer.put(Buffer.of(Field.FieldType.Binary, 0x00, 0x00, 0x00, 0x00)); 138 | const data = await Field.readField(socket, Field.FieldType.Binary); 139 | 140 | expect(data).toBeInstanceOf(Field.Binary); 141 | expect(data.value).toBeInstanceOf(Buffer); 142 | expect(data.value).toEqual(Buffer.of()); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/localdb/orm.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'better-sqlite3'; 2 | import {camelCase, mapKeys, mapValues, partition, snakeCase} from 'lodash'; 3 | 4 | import {EntityFK, Playlist, PlaylistEntry, Track} from 'src/entities'; 5 | 6 | import {generateSchema} from './schema'; 7 | 8 | /** 9 | * Table names available 10 | */ 11 | export enum Table { 12 | Artist = 'artist', 13 | Album = 'album', 14 | Genre = 'genre', 15 | Color = 'color', 16 | Label = 'label', 17 | Key = 'key', 18 | Artwork = 'artwork', 19 | Playlist = 'playlist', 20 | PlaylistEntry = 'playlist_entry', 21 | Track = 'track', 22 | } 23 | 24 | const trackRelations = [ 25 | 'artwork', 26 | 'artist', 27 | 'originalArtist', 28 | 'remixer', 29 | 'composer', 30 | 'album', 31 | 'label', 32 | 'genre', 33 | 'color', 34 | 'key', 35 | ]; 36 | 37 | const trackRelationTableMap: Record = { 38 | originalArtist: 'artist', 39 | remixer: 'artist', 40 | composer: 'artist', 41 | }; 42 | 43 | /** 44 | * Object Relation Mapper as an abstraction ontop of a local database 45 | * connection. 46 | * 47 | * May be used to populate a metadata database and query objects. 48 | */ 49 | export class MetadataORM { 50 | #conn: sqlite3.Database; 51 | 52 | constructor() { 53 | this.#conn = sqlite3(':memory:'); 54 | this.#conn.exec(generateSchema()); 55 | } 56 | 57 | close() { 58 | this.#conn.close(); 59 | } 60 | 61 | /** 62 | * Insert a entity object into the database. 63 | */ 64 | insertEntity(table: Table, object: Record) { 65 | const fields = Object.entries(object); 66 | 67 | const slots = fields.map(f => `:${f[0]}`).join(', '); 68 | const columns = fields.map(f => snakeCase(f[0])).join(', '); 69 | 70 | const stmt = this.#conn.prepare( 71 | `insert into ${table} (${columns}) values (${slots})` 72 | ); 73 | 74 | // Translate date and booleans 75 | const data = mapValues(object, value => 76 | value instanceof Date 77 | ? value.toISOString() 78 | : typeof value === 'boolean' 79 | ? Number(value) 80 | : value 81 | ); 82 | 83 | stmt.run(data); 84 | } 85 | 86 | /** 87 | * Locate a track by ID in the database 88 | */ 89 | findTrack(id: number): Track { 90 | const row: Record = this.#conn 91 | .prepare(`select * from ${Table.Track} where id = ?`) 92 | .get(id); 93 | 94 | // Map row columns to camel case compatibility 95 | const trackRow = mapKeys(row, (_, k) => camelCase(k)) as Track; 96 | 97 | trackRow.beatGrid = null; 98 | trackRow.cueAndLoops = null; 99 | trackRow.waveformHd = null; 100 | 101 | // Explicitly restore dates and booleans 102 | trackRow.autoloadHotcues = !!trackRow.autoloadHotcues; 103 | trackRow.kuvoPublic = !!trackRow.kuvoPublic; 104 | 105 | // Explicitly restore date objects 106 | trackRow.analyzeDate = new Date(trackRow.analyzeDate as any); 107 | trackRow.dateAdded = new Date(trackRow.dateAdded as any); 108 | 109 | // Query all track relationships 110 | const track = trackRow as any; 111 | 112 | for (const relation of trackRelations) { 113 | const fkName = `${relation}Id`; 114 | 115 | const fk = track[fkName]; 116 | const table = snakeCase(trackRelationTableMap[relation] ?? relation); 117 | 118 | // Swap fk for relation key 119 | delete track[fkName]; 120 | track[relation] = null; 121 | 122 | if (fk === null) { 123 | continue; 124 | } 125 | 126 | const relationItem: Record = this.#conn 127 | .prepare(`select * from ${table} where id = ?`) 128 | .get(fk); 129 | 130 | track[relation] = relationItem; 131 | } 132 | 133 | return track as Track; 134 | } 135 | 136 | /** 137 | * Query for a list of {folders, playlists, tracks} given a playlist ID. If 138 | * no ID is provided the root list is queried. 139 | * 140 | * Note that when tracks are returned there will be no folders or playslists. 141 | * But the API here is simpler to assume there could be. 142 | * 143 | * Tracks are returned in the order they are placed on the playlist. 144 | */ 145 | findPlaylist(playlistId?: number) { 146 | const parentCondition = playlistId === undefined ? 'parent_id is ?' : 'parent_id = ?'; 147 | 148 | // Lookup playlists / folders for this playlist ID 149 | const playlistRows: Array> = this.#conn 150 | .prepare(`select * from ${Table.Playlist} where ${parentCondition}`) 151 | .all(playlistId); 152 | 153 | const [folders, playlists] = partition( 154 | playlistRows.map(row => mapKeys(row, (_, k) => camelCase(k)) as Playlist), 155 | p => p.isFolder 156 | ); 157 | 158 | const entryRows: Array> = this.#conn 159 | .prepare(`select * from ${Table.PlaylistEntry} where playlist_id = ?`) 160 | .all(playlistId); 161 | 162 | const trackEntries = entryRows.map( 163 | row => mapKeys(row, (_, k) => camelCase(k)) as PlaylistEntry 164 | ); 165 | 166 | return {folders, playlists, trackEntries}; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | prolink-connect 3 |

4 | 5 |

6 | Pioneer's PRO DJ LINK protocol, unlocked. 7 |
8 | Consume CDJ states + Retrieve complete track metadata. 9 |

10 | 11 |

12 | build 13 | npm 14 |

15 | 16 | --- 17 | 18 | This library implements the Pioneer PROLINK network protocol + additional 19 | functionality to interact with the prolink network. This library is used as 20 | part of [Prolink Tools](https://prolink.tools/). 21 | 22 | Alternative implementations of the Prolink protocol: [Java](https://github.com/Deep-Symmetry/beat-link), [golang](https://github.com/evanpurkhiser/prolink-go). 23 | 24 | Thank you to [@brunchboy](https://github.com/brunchboy) for his work on 25 | [dysentery](https://github.com/brunchboy/dysentery). 26 | 27 | ## Features 28 | 29 | - **Written in Typescript** - Accurate typings making implementation a breeze. 30 | Autocompete your DJ tools to completion. 31 | 32 | - **CDJ Status** - Receive Player state details for each CDJ on the network. 33 | The status is reported as a [`CDJStatus.State`](https://connect.prolink.tools/modules/_src_status_types_.html). 34 | 35 | - **Metadata Database** - Access metadata of currently the currently playing 36 | (or not!) tracks stored in the connected Rekordbox formatted USB / SD 37 | device, or via Rekordbox link. 38 | 39 | ## Library usage 40 | 41 | ### Connecting to the network 42 | 43 | To talk with Prolink devices on the network you'll first need to... 44 | 45 | 1. Bring the network online 46 | 2. Configure the network to be connected to. 47 | 3. Connect to the devices on the network 48 | 49 | ```ts 50 | import {bringOnline} from 'prolink-connect'; 51 | 52 | async function main() { 53 | // Bring the prolink network online. 54 | // 55 | // This will begin listening for prolink devices on the network that send 56 | // regular announcement packets over UDP. 57 | // 58 | // This will FAIL if Rekordbox is running on the same computer, or a second 59 | // instance of the prolink-connect library is running on the same machine. 60 | console.info('Bringing the network online'); 61 | const network = await bringOnline(); 62 | 63 | // Once online we can listen for appearing on the network 64 | network.deviceManager.on('connected', device => 65 | console.log('New device on network:', device) 66 | ); 67 | 68 | // To configure the online network to be "connected" we must need to specify 69 | // what network device to use to announce ourselves as a "virtual" device 70 | // onto the network, and what ID we want to announce ourselves as. By 71 | // announcing ourselves this will cause other devices to send us more detailed 72 | // information. 73 | // 74 | // There are two ways to configure the network: 75 | // 76 | // 1. Automatically - You can ask prolink-connect to wait for a device to 77 | // appear on the network to determine what network interface devices exist 78 | // on. Device ID 5 will be used in auto configure mode. 79 | // 80 | // 2. Manually - In this case you will need to manually specify the network 81 | // device and device ID. 82 | // 83 | // NOTES on the Device ID: 84 | // 85 | // It's recommended that you use a Device ID of `5` for the virtual device. 86 | // Using a ID between 1 - 6 will take up ONE SLOT on the network that normally 87 | // a CDJ would occupy. When a 1-6 ID is used You may ONLY HAVE 5 CDJs on the 88 | // network. Attempting to connect a 6th CDJ will conflict with the virtual 89 | // device announced on the network by prolink-connect. (On models older than 90 | // 2000s the rande is 1-4.) 91 | // 92 | // There are some cases where you may want your virtual device to announce 93 | // itself with "real" device ID, but this library does not currently support 94 | // the scenarios that would requrie that (Becoming master and sending a master 95 | // tempo) 96 | 97 | // 1. AUTO CONFIGURATION 98 | console.info('Auto configuring the network'); 99 | await network.autoconfigFromPeers(); 100 | 101 | // 2. MANUAL CONFIGURATION 102 | // 103 | // const configuredIface = getNetworkInterfaceInfoIPv4() 104 | // network.configure({vcdjId: 2, iface: configuredIface}) 105 | 106 | // We can now connect to the network. 107 | // 108 | // This will begin announcing ourself on the network, as well as enable various 109 | // services on the network service object. 110 | console.info('Connecting to the network'); 111 | await network.connect(); 112 | 113 | // If you're using trypescript, you can now type guard [0] to coerce the type 114 | // to ProlinkNetworkConnected, marking all services as non-null. 115 | // 116 | // [0]: https://www.typescriptlang.org/docs/handbook/advanced-types.html#using-type-predicates 117 | // 118 | // You don't need to do this if you're not using trypescript 119 | if (!network.isConnected()) { 120 | console.error('Failed to connect to the network'); 121 | return; 122 | } 123 | } 124 | ``` 125 | -------------------------------------------------------------------------------- /tests/devices/index.spec.ts: -------------------------------------------------------------------------------- 1 | import {mockDevice} from 'tests/utils'; 2 | 3 | import {Socket} from 'dgram'; 4 | import {EventEmitter} from 'events'; 5 | 6 | import {VIRTUAL_CDJ_NAME} from 'src/constants'; 7 | import DeviceManager from 'src/devices'; 8 | import {deviceFromPacket} from 'src/devices/utils'; 9 | 10 | jest.mock('src/devices/utils', () => ({ 11 | deviceFromPacket: jest.fn(), 12 | })); 13 | 14 | const dfpMock = deviceFromPacket as jest.Mock>; 15 | 16 | jest.useFakeTimers(); 17 | 18 | describe('DeviceManager', () => { 19 | const mockSocket = new EventEmitter() as Socket; 20 | 21 | it('produces device lifecycle events', () => { 22 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 23 | 24 | const announceFn = jest.fn(); 25 | dm.on('announced', announceFn); 26 | 27 | const connectedFn = jest.fn(); 28 | dm.on('connected', connectedFn); 29 | 30 | const disconnectedFn = jest.fn(); 31 | dm.on('disconnected', disconnectedFn); 32 | 33 | // Mocked message value 34 | const deadBeef = Buffer.from([0xde, 0xad, 0xbe, 0xef]); 35 | 36 | const deviceExample = mockDevice(); 37 | 38 | dfpMock.mockReturnValue(deviceExample); 39 | 40 | // Trigger device announcement 41 | mockSocket.emit('message', deadBeef); 42 | 43 | expect(deviceFromPacket).toHaveBeenCalledWith(deadBeef); 44 | expect(connectedFn).toHaveBeenCalledWith(deviceExample); 45 | expect(announceFn).toHaveBeenCalledWith(deviceExample); 46 | expect(dm.devices.size).toBe(1); 47 | expect(dm.devices.get(1)).toBe(deviceExample); 48 | 49 | // Reset our emitter mocks for the next announcement 50 | announceFn.mockClear(); 51 | connectedFn.mockReset(); 52 | 53 | // Move forward 75ms, the device should not have timed out yet 54 | jest.advanceTimersByTime(75); 55 | 56 | // Trigger device announcement 57 | mockSocket.emit('message', deadBeef); 58 | 59 | expect(connectedFn).not.toHaveBeenCalled(); 60 | expect(announceFn).toHaveBeenCalledWith(deviceExample); 61 | 62 | // Device is still kept alive, as it has not expired since its last 63 | // announcement 64 | jest.advanceTimersByTime(75); 65 | 66 | // Device will now timeout 67 | jest.advanceTimersByTime(25); 68 | expect(disconnectedFn).toHaveBeenCalledWith(deviceExample); 69 | expect(dm.devices.size).toBe(0); 70 | 71 | // Reconfigure for longer timeout 72 | dm.reconfigure({deviceTimeout: 500}); 73 | 74 | // Device reconnects and emits a new connection event 75 | connectedFn.mockReset(); 76 | mockSocket.emit('message', deadBeef); 77 | 78 | expect(connectedFn).toHaveBeenCalledWith(deviceExample); 79 | 80 | disconnectedFn.mockReset(); 81 | 82 | // Device will not timeout with reconfigured timeout 83 | jest.advanceTimersByTime(400); 84 | expect(disconnectedFn).not.toHaveBeenCalled(); 85 | 86 | // Device will now timeout 87 | jest.advanceTimersByTime(100); 88 | expect(disconnectedFn).toHaveBeenCalledWith(deviceExample); 89 | }); 90 | 91 | it('does not announce invalid announce packets', () => { 92 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 93 | 94 | const announceFn = jest.fn(); 95 | dm.on('announced', announceFn); 96 | 97 | dfpMock.mockReturnValue(null); 98 | 99 | // Trigger device announcement 100 | mockSocket.emit('message', Buffer.of()); 101 | 102 | expect(announceFn).not.toHaveBeenCalled(); 103 | expect(dm.devices.size).toBe(0); 104 | }); 105 | 106 | it('does not announce or track virtual CDJ announcements', () => { 107 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 108 | 109 | const announceFn = jest.fn(); 110 | dm.on('announced', announceFn); 111 | 112 | const deviceExample = mockDevice({name: VIRTUAL_CDJ_NAME}); 113 | 114 | dfpMock.mockReturnValue(deviceExample); 115 | 116 | // Trigger device announcement 117 | mockSocket.emit('message', Buffer.of()); 118 | 119 | expect(announceFn).not.toHaveBeenCalled(); 120 | expect(dm.devices.size).toBe(0); 121 | }); 122 | 123 | it('waits for a device to appear using getDeviceEnsured', async () => { 124 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 125 | 126 | const deviceExample = mockDevice(); 127 | const gotDevice = dm.getDeviceEnsured(1); 128 | 129 | jest.advanceTimersByTime(75); 130 | 131 | dfpMock.mockReturnValue(deviceExample); 132 | 133 | // Trigger device announcement 134 | mockSocket.emit('message', Buffer.of()); 135 | 136 | await expect(gotDevice).resolves.toBe(deviceExample); 137 | }); 138 | 139 | it('timesout when waiting for device using getDeviceEnsured', async () => { 140 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 141 | 142 | const gotDevice = dm.getDeviceEnsured(1, 150); 143 | 144 | jest.advanceTimersByTime(150); 145 | await expect(gotDevice).resolves.toBe(null); 146 | }); 147 | 148 | it('immedaitely returns a device when it already exists using getDeviceEnsured', async () => { 149 | const dm = new DeviceManager(mockSocket, {deviceTimeout: 100}); 150 | const deviceExample = mockDevice(); 151 | dfpMock.mockReturnValue(deviceExample); 152 | mockSocket.emit('message', Buffer.of()); 153 | 154 | await expect(dm.getDeviceEnsured(1, 100)).resolves.toBe(deviceExample); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/devices/index.ts: -------------------------------------------------------------------------------- 1 | import StrictEventEmitter from 'strict-event-emitter-types'; 2 | 3 | import {Socket} from 'dgram'; 4 | import {EventEmitter} from 'events'; 5 | 6 | import {VIRTUAL_CDJ_NAME} from 'src/constants'; 7 | import {Device, DeviceID} from 'src/types'; 8 | 9 | import {deviceFromPacket} from './utils'; 10 | 11 | interface Config { 12 | /** 13 | * Time in milliseconds after which a device is considered to have 14 | * disconnected if it has not broadcast an announcement. 15 | * 16 | * @default 10000 ms 17 | */ 18 | deviceTimeout?: number; 19 | } 20 | 21 | const defaultConfig = { 22 | deviceTimeout: 10000, 23 | }; 24 | 25 | /** 26 | * The upper bound in milliseconds to wait when looking for a device to be on 27 | * the network when using the `getDeviceEnsured` method. 28 | */ 29 | const ENSURED_TIMEOUT = 2000; 30 | 31 | /** 32 | * The configuration object that may be passed to reconfigure the manager 33 | */ 34 | type ConfigEditable = Omit; 35 | 36 | /** 37 | * The interface the device manager event emitter should follow 38 | */ 39 | interface DeviceEvents { 40 | /** 41 | * Fired when a new device becomes available on the network 42 | */ 43 | connected: (device: Device) => void; 44 | /** 45 | * Fired when a device has not announced itself on the network for the 46 | * specified timeout. 47 | */ 48 | disconnected: (device: Device) => void; 49 | /** 50 | * Fired every time the device announces itself on the network 51 | */ 52 | announced: (device: Device) => void; 53 | } 54 | 55 | type Emitter = StrictEventEmitter; 56 | 57 | /** 58 | * The device manager is responsible for tracking devices that appear on the 59 | * prolink network, providing an API to react to devices livecycle events as 60 | * they connect and disconnect form the network. 61 | */ 62 | class DeviceManager { 63 | /** 64 | * Device manager configuration 65 | */ 66 | #config: Required; 67 | /** 68 | * The map of all active devices currently available on the network. 69 | */ 70 | #devices = new Map(); 71 | /** 72 | * Tracks device timeout handlers, as devices announce themselves these 73 | * timeouts will be updated. 74 | */ 75 | #deviceTimeouts = new Map(); 76 | /** 77 | * The EventEmitter which will be used to trigger device lifecycle events 78 | */ 79 | #emitter: Emitter = new EventEmitter(); 80 | 81 | constructor(announceSocket: Socket, config?: Config) { 82 | this.#config = {...defaultConfig, ...config}; 83 | 84 | // Begin listening for device announcements 85 | announceSocket.on('message', this.#handleAnnounce); 86 | } 87 | 88 | // Bind public event emitter interface 89 | on: Emitter['on'] = this.#emitter.addListener.bind(this.#emitter); 90 | off: Emitter['off'] = this.#emitter.removeListener.bind(this.#emitter); 91 | once: Emitter['once'] = this.#emitter.once.bind(this.#emitter); 92 | 93 | /** 94 | * Get active devices on the network. 95 | */ 96 | get devices() { 97 | return this.#devices; 98 | } 99 | 100 | /** 101 | * Waits for a specific device ID to appear on the network, with a 102 | * configurable timeout, in which case it will resolve with null. 103 | */ 104 | async getDeviceEnsured(id: DeviceID, timeout: number = ENSURED_TIMEOUT) { 105 | const existingDevice = this.devices.get(id); 106 | 107 | if (existingDevice !== undefined) { 108 | return existingDevice; 109 | } 110 | 111 | let handler: ((device: Device) => void) | undefined; 112 | 113 | // Wait for the device to be connected 114 | const devicePromise = new Promise(resolve => { 115 | handler = (device: Device) => device.id === id && resolve(device); 116 | this.on('connected', handler); 117 | }); 118 | 119 | const device = await Promise.race([ 120 | devicePromise, 121 | new Promise(r => setTimeout(() => r(null), timeout)), 122 | ]); 123 | this.off('connected', handler!); 124 | 125 | return device; 126 | } 127 | 128 | reconfigure(config: ConfigEditable) { 129 | this.#config = {...this.#config, ...config}; 130 | } 131 | 132 | #handleAnnounce = (message: Buffer) => { 133 | const device = deviceFromPacket(message); 134 | 135 | if (device === null) { 136 | return; 137 | } 138 | 139 | if (device.name === VIRTUAL_CDJ_NAME) { 140 | return; 141 | } 142 | 143 | // Device has not checked in before 144 | if (!this.#devices.has(device.id)) { 145 | this.#devices.set(device.id, device); 146 | this.#emitter.emit('connected', device); 147 | } 148 | 149 | this.#emitter.emit('announced', device); 150 | 151 | // Reset the device timeout handler 152 | const activeTimeout = this.#deviceTimeouts.get(device.id); 153 | if (activeTimeout) { 154 | clearTimeout(activeTimeout); 155 | } 156 | 157 | const timeout = this.#config.deviceTimeout; 158 | const newTimeout = setTimeout(this.#handleDisconnect, timeout, device); 159 | this.#deviceTimeouts.set(device.id, newTimeout); 160 | }; 161 | 162 | #handleDisconnect = (removedDevice: Device) => { 163 | this.#devices.delete(removedDevice.id); 164 | this.#deviceTimeouts.delete(removedDevice.id); 165 | 166 | this.#emitter.emit('disconnected', removedDevice); 167 | }; 168 | } 169 | 170 | export default DeviceManager; 171 | -------------------------------------------------------------------------------- /src/nfs/rpc.ts: -------------------------------------------------------------------------------- 1 | import {Mutex} from 'async-mutex'; 2 | import promiseRetry from 'promise-retry'; 3 | import {timeout, TimeoutError} from 'promise-timeout'; 4 | import {OperationOptions} from 'retry'; 5 | 6 | import dgram, {Socket} from 'dgram'; 7 | 8 | import {udpClose, udpRead, udpSend} from 'src/utils/udp'; 9 | 10 | import {rpc} from './xdr'; 11 | 12 | /** 13 | * The RPC auth stamp passed by the CDJs. It's unclear if this is actually 14 | * important, but I'm keeping the rpc calls as close to CDJ calls as I can. 15 | */ 16 | const CDJ_AUTH_STAMP = 0x967b8703; 17 | 18 | const rpcAuthMessage = new rpc.UnixAuth({ 19 | stamp: CDJ_AUTH_STAMP, 20 | name: '', 21 | uid: 0, 22 | gid: 0, 23 | gids: [], 24 | }); 25 | 26 | interface RpcCall { 27 | port: number; 28 | program: number; 29 | version: number; 30 | procedure: number; 31 | data: Buffer; 32 | } 33 | 34 | /** 35 | * Configuration for the retry strategy to use when making RPC calls 36 | * 37 | * @see https://www.npmjs.com/package/promise-retry#promiseretryfn-options 38 | */ 39 | export type RetryConfig = OperationOptions & { 40 | /** 41 | * Time in milliseconds to wait before a RPC transaction should timeout. 42 | * @default 1000 43 | */ 44 | transactionTimeout?: number; 45 | }; 46 | 47 | /** 48 | * Generic RPC connection. Can be used to make RPC 2 calls to any program 49 | * specified in the RpcCall. 50 | */ 51 | export class RpcConnection { 52 | address: string; 53 | retryConfig: RetryConfig; 54 | socket: Socket; 55 | mutex: Mutex; 56 | xid = 1; 57 | 58 | constructor(address: string, retryConfig?: RetryConfig) { 59 | this.address = address; 60 | this.retryConfig = retryConfig ?? {}; 61 | this.socket = dgram.createSocket('udp4'); 62 | this.mutex = new Mutex(); 63 | } 64 | 65 | // TODO: Turn this into a getter and figure out what logic we can do here 66 | // to determine if the socket is still open. 67 | connected = true; 68 | 69 | setupRequest({program, version, procedure, data}: Omit) { 70 | const auth = new rpc.Auth({ 71 | flavor: 1, 72 | body: rpcAuthMessage.toXDR(), 73 | }); 74 | 75 | const verifier = new rpc.Auth({ 76 | flavor: 0, 77 | body: Buffer.alloc(0), 78 | }); 79 | 80 | const request = new rpc.Request({ 81 | rpcVersion: rpc.Version, 82 | programVersion: version, 83 | program, 84 | procedure, 85 | auth, 86 | verifier, 87 | data, 88 | }); 89 | 90 | const packet = new rpc.Packet({ 91 | xid: this.xid, 92 | message: rpc.Message.request(request), 93 | }); 94 | 95 | return packet.toXDR(); 96 | } 97 | 98 | /** 99 | * Execute a RPC transaction (call and response). 100 | * 101 | * If a transaction does not complete after the configured timeout it will be 102 | * retried with the retry configuration. 103 | */ 104 | async call({port, ...call}: RpcCall) { 105 | this.xid++; 106 | 107 | const callData = this.setupRequest(call); 108 | 109 | // Function to execute the transaction 110 | const executeCall = async () => { 111 | await udpSend(this.socket, callData, 0, callData.length, port, this.address); 112 | return udpRead(this.socket); 113 | }; 114 | 115 | const {transactionTimeout, ...retryConfig} = this.retryConfig; 116 | 117 | // Function to execute the transaction, with timeout if the transaction 118 | // does not resolve after RESPONSE_RETRY_TIMEOUT. 119 | const executeWithTimeout = () => timeout(executeCall(), transactionTimeout ?? 1000); 120 | 121 | // Function to execute the transaction, with retries if the transaction times out. 122 | const executeWithRetry = () => 123 | promiseRetry(retryConfig, async retry => { 124 | try { 125 | return await executeWithTimeout(); 126 | } catch (err) { 127 | if (err instanceof TimeoutError) { 128 | retry(err); 129 | } else { 130 | throw err; 131 | } 132 | } 133 | return undefined; 134 | }); 135 | 136 | // Execute the transaction exclusively to avoid async call races 137 | const resp = await this.mutex.runExclusive(executeWithRetry); 138 | 139 | // Decode the XDR response 140 | const packet = rpc.Packet.fromXDR(resp); 141 | 142 | const message = packet.message().response(); 143 | if (message.arm() !== 'accepted') { 144 | throw new Error('RPC request was denied'); 145 | } 146 | 147 | const body = message.accepted().response(); 148 | if (body.arm() !== 'success') { 149 | throw new Error('RPC did not successfully return data'); 150 | } 151 | 152 | return body.success() as Buffer; 153 | } 154 | 155 | async disconnect() { 156 | await udpClose(this.socket); 157 | } 158 | } 159 | 160 | type RpcProgramCall = Pick; 161 | 162 | /** 163 | * RpcProgram is constructed with specialization details for a specific RPC 164 | * program. This should be used to avoid having to repeat yourself for calls 165 | * made using the RpcConnection. 166 | */ 167 | export class RpcProgram { 168 | program: number; 169 | version: number; 170 | port: number; 171 | conn: RpcConnection; 172 | 173 | constructor(conn: RpcConnection, program: number, version: number, port: number) { 174 | this.conn = conn; 175 | this.program = program; 176 | this.version = version; 177 | this.port = port; 178 | } 179 | 180 | call(data: RpcProgramCall) { 181 | const {program, version, port} = this; 182 | return this.conn.call({program, version, port, ...data}); 183 | } 184 | 185 | disconnect() { 186 | this.conn.disconnect(); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/remotedb/message/index.ts: -------------------------------------------------------------------------------- 1 | import {Span, SpanStatus} from '@sentry/tracing'; 2 | import {PromiseReadable} from 'promise-readable'; 3 | 4 | import {REMOTEDB_MAGIC} from 'src/remotedb/constants'; 5 | import { 6 | Binary, 7 | Field, 8 | FieldType, 9 | readField, 10 | UInt8, 11 | UInt16, 12 | UInt32, 13 | } from 'src/remotedb/fields'; 14 | import {responseTransform} from 'src/remotedb/message/response'; 15 | import {getMessageName, MessageType, Response} from 'src/remotedb/message/types'; 16 | 17 | /** 18 | * Argument types are used in argument list fields. This is essentially 19 | * duplicating the field type, but has different values for whatever reason. 20 | * 21 | * There do not appear to be argument types for UInt8 and UInt16. At least, no 22 | * messages include these field types as arguments as far as we know. 23 | */ 24 | enum ArgumentType { 25 | String = 0x02, 26 | Binary = 0x03, 27 | UInt32 = 0x06, 28 | } 29 | 30 | /** 31 | * The message argument list always contains 12 slots 32 | */ 33 | const ARG_COUNT = 12; 34 | 35 | const fieldArgsMap = { 36 | [FieldType.UInt32]: ArgumentType.UInt32, 37 | [FieldType.String]: ArgumentType.String, 38 | [FieldType.Binary]: ArgumentType.Binary, 39 | 40 | // The following two field types do not have associated argument types (see 41 | // the note in ArgumentType), but we declare them here to make typescript happy 42 | // when mapping these values over. 43 | [FieldType.UInt8]: 0x00, 44 | [FieldType.UInt16]: 0x00, 45 | }; 46 | 47 | const argsFieldMap = { 48 | [ArgumentType.UInt32]: FieldType.UInt32, 49 | [ArgumentType.String]: FieldType.String, 50 | [ArgumentType.Binary]: FieldType.Binary, 51 | }; 52 | 53 | interface Options { 54 | transactionId?: number; 55 | type: T; 56 | args: Field[]; 57 | } 58 | 59 | type ResponseType = T extends Response ? T : never; 60 | type Data = ReturnType<(typeof responseTransform)[ResponseType]>; 61 | 62 | /** 63 | * Representation of a set of fields sequenced into a known message format. 64 | */ 65 | export class Message { 66 | /** 67 | * Read a single mesasge via a readable stream 68 | */ 69 | static async fromStream( 70 | stream: PromiseReadable, 71 | expect: T, 72 | span: Span 73 | ) { 74 | const tx = span.startChild({ 75 | op: 'readFromStream', 76 | description: getMessageName(expect), 77 | }); 78 | 79 | // 01. Read magic bytes 80 | const magicHeader = await readField(stream, FieldType.UInt32); 81 | 82 | if (magicHeader.value !== REMOTEDB_MAGIC) { 83 | throw new Error('Did not receive expected magic value. Corrupt message'); 84 | } 85 | 86 | // 02. Read transaction ID 87 | const txId = await readField(stream, FieldType.UInt32); 88 | 89 | // 03. Read message type 90 | const messageType = await readField(stream, FieldType.UInt16); 91 | 92 | // 04. Read argument count 93 | const argCount = await readField(stream, FieldType.UInt8); 94 | 95 | // 05. Read argument list 96 | const argList = await readField(stream, FieldType.Binary); 97 | 98 | // 06. Read all argument fields in 99 | const args: Field[] = new Array(argCount.value); 100 | 101 | for (let i = 0; i < argCount.value; ++i) { 102 | // XXX: There is a small quirk in a few message response types that send 103 | // binary data, but if the binary data is empty the field will not 104 | // be sent. 105 | if (argList.value[i] === ArgumentType.Binary && args[i - 1]?.value === 0) { 106 | args[i] = new Binary(Buffer.alloc(0)); 107 | continue; 108 | } 109 | 110 | args[i] = await readField(stream, argsFieldMap[argList.value[i] as ArgumentType]); 111 | } 112 | 113 | if (messageType.value !== expect) { 114 | const expected = expect.toString(16); 115 | const actual = messageType.value.toString(16); 116 | 117 | tx.setStatus(SpanStatus.FailedPrecondition); 118 | tx.finish(); 119 | 120 | throw new Error(`Expected message type 0x${expected}, got 0x${actual}`); 121 | } 122 | 123 | tx.finish(); 124 | 125 | return new Message({ 126 | transactionId: txId.value, 127 | type: messageType.value as T, 128 | args, 129 | }); 130 | } 131 | 132 | /** 133 | * The transaction ID is used to associate responses to their requests. 134 | */ 135 | transactionId?: number; 136 | 137 | readonly type: T; 138 | readonly args: Field[]; 139 | 140 | constructor({transactionId, type, args}: Options) { 141 | this.transactionId = transactionId; 142 | this.type = type; 143 | this.args = args; 144 | } 145 | 146 | /** 147 | * The byte serialization of the message 148 | */ 149 | get buffer() { 150 | // Determine the argument list from the list of fields 151 | const argList = Buffer.alloc(ARG_COUNT, 0x00); 152 | argList.set(this.args.map(arg => fieldArgsMap[arg.constructor.type])); 153 | 154 | // XXX: Following the parsing quirk for messages that contain binary data 155 | // but are _empty_, we check for binary fields with UInt32 fields 156 | // before with the value of 0 (indicating "an empty binary field"). 157 | const args = this.args.reduce((args, arg, i) => { 158 | const prevArg = this.args[i - 1]; 159 | 160 | const isEmptyBuffer = 161 | arg.constructor.type === FieldType.Binary && 162 | i !== 0 && 163 | prevArg.constructor.type === FieldType.UInt32 && 164 | prevArg.value === 0; 165 | 166 | return isEmptyBuffer ? args : [...args, arg]; 167 | }, []); 168 | 169 | const fields = [ 170 | new UInt32(REMOTEDB_MAGIC), 171 | new UInt32(this.transactionId ?? 0), 172 | new UInt16(this.type), 173 | new UInt8(this.args.length), 174 | new Binary(argList), 175 | ...args, 176 | ]; 177 | 178 | return Buffer.concat(fields.map(f => f.buffer)); 179 | } 180 | 181 | /** 182 | * The JS representation of the message. 183 | * 184 | * Currently only supports representing response messages. 185 | */ 186 | get data(): Data { 187 | const type = this.type as ResponseType; 188 | 189 | if (!Object.values(Response).includes(type)) { 190 | throw new Error('Representation of non-responses is not currently supported'); 191 | } 192 | 193 | return responseTransform[type](this.args) as Data; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/nfs/programs.ts: -------------------------------------------------------------------------------- 1 | import {Span} from '@sentry/tracing'; 2 | 3 | import {RpcConnection, RpcProgram} from './rpc'; 4 | import {flattenLinkedList} from './utils'; 5 | import {mount, nfs, portmap} from './xdr'; 6 | import {FetchProgress} from '.'; 7 | 8 | /** 9 | * How many bytes of a file should we read at once. 10 | */ 11 | const READ_SIZE = 2048; 12 | 13 | interface Program { 14 | id: number; 15 | version: number; 16 | } 17 | 18 | /** 19 | * Queries for the listening port of a RPC program 20 | */ 21 | export async function makeProgramClient(conn: RpcConnection, program: Program) { 22 | const getPortData = new portmap.GetPort({ 23 | program: program.id, 24 | version: program.version, 25 | protocol: 17, // UDP protocol 26 | port: 0, 27 | }); 28 | 29 | const data = await conn.call({ 30 | port: 111, 31 | program: portmap.Program, 32 | version: portmap.Version, 33 | procedure: portmap.Procedure.getPort().value, 34 | data: getPortData.toXDR(), 35 | }); 36 | 37 | const port = data.readInt32BE(); 38 | 39 | return new RpcProgram(conn, program.id, program.version, port); 40 | } 41 | 42 | /** 43 | * Export represents a NFS export on a remote system 44 | */ 45 | interface Export { 46 | /** 47 | * The name of the exported filesystem 48 | */ 49 | filesystem: string; 50 | /** 51 | * The groups allowed to mount this filesystem 52 | */ 53 | groups: string[]; 54 | } 55 | 56 | /** 57 | * Attributes a remote file 58 | */ 59 | export interface FileInfo { 60 | handle: Buffer; 61 | name: string; 62 | size: number; 63 | type: 'null' | 'regular' | 'directory' | 'block' | 'char' | 'link'; 64 | } 65 | 66 | /** 67 | * Request a list of export entries. 68 | */ 69 | export async function getExports(conn: RpcProgram, span?: Span) { 70 | const tx = span?.startChild({op: 'getExports'}); 71 | 72 | const data = await conn.call({ 73 | procedure: mount.Procedure.export().value, 74 | data: Buffer.alloc(0), 75 | }); 76 | 77 | const entry = mount.ExportListResponse.fromXDR(data).next(); 78 | if (entry === undefined) { 79 | return []; 80 | } 81 | 82 | const exports = flattenLinkedList(entry).map((entry: any) => ({ 83 | filesystem: entry.filesystem(), 84 | groups: flattenLinkedList(entry.groups()).map((g: any) => g.name().toString()), 85 | })); 86 | 87 | tx?.finish(); 88 | 89 | return exports as Export[]; 90 | } 91 | 92 | /** 93 | * Mount the specified export, returning the file handle. 94 | */ 95 | export async function mountFilesystem( 96 | conn: RpcProgram, 97 | {filesystem}: Export, 98 | span?: Span 99 | ) { 100 | const tx = span?.startChild({op: 'mountFilesystem', data: {filesystem}}); 101 | 102 | const resp = await conn.call({ 103 | procedure: mount.Procedure.mount().value, 104 | data: new mount.MountRequest({filesystem}).toXDR(), 105 | }); 106 | 107 | const fileHandleResp = mount.FHStatus.fromXDR(resp); 108 | if (fileHandleResp.arm() !== 'success') { 109 | throw new Error('Failed to mount filesystem'); 110 | } 111 | 112 | tx?.finish(); 113 | 114 | return fileHandleResp.success() as Buffer; 115 | } 116 | 117 | /** 118 | * Lookup a file within the directory of the provided file handle, returning 119 | * the FileInfo object if the file can be located. 120 | */ 121 | export async function lookupFile( 122 | conn: RpcProgram, 123 | handle: Buffer, 124 | filename: string, 125 | span?: Span 126 | ) { 127 | const tx = span?.startChild({op: 'lookupFile', description: filename}); 128 | 129 | const resp = await conn.call({ 130 | procedure: nfs.Procedure.lookup().value, 131 | data: new nfs.DirectoryOpArgs({handle, filename}).toXDR(), 132 | }); 133 | 134 | const fileResp = nfs.DirectoryOpResponse.fromXDR(resp); 135 | if (fileResp.arm() !== 'success') { 136 | throw new Error(`Failed file lookup of ${filename}`); 137 | } 138 | 139 | const fileHandle = fileResp.success().handle(); 140 | const attributes = fileResp.success().attributes(); 141 | 142 | const info: FileInfo = { 143 | name: filename, 144 | handle: fileHandle, 145 | size: attributes.size(), 146 | type: attributes.type().name, 147 | }; 148 | 149 | tx?.finish(); 150 | 151 | return info; 152 | } 153 | 154 | /** 155 | * Lookup the absolute path to a file, given the root file handle and path, 156 | */ 157 | export async function lookupPath( 158 | conn: RpcProgram, 159 | rootHandle: Buffer, 160 | filepath: string, 161 | span?: Span 162 | ) { 163 | const tx = span?.startChild({op: 'lookupPath', description: filepath}); 164 | 165 | // There are times when the path includes a leading slash, sanitize that 166 | const pathParts = filepath.replace(/^\//, '').split('/'); 167 | 168 | let handle: Buffer = rootHandle; 169 | let info: FileInfo; 170 | 171 | while (pathParts.length !== 0) { 172 | const filename = pathParts.shift()!; 173 | const fileInfo = await lookupFile(conn, handle, filename, tx); 174 | 175 | info = fileInfo; 176 | handle = info.handle; 177 | } 178 | 179 | tx?.finish(); 180 | 181 | // We can guarantee this will be set since we will have failed to lookup the 182 | // file above 183 | return info!; 184 | } 185 | 186 | /** 187 | * Fetch the specified file the remote NFS server. This will read the entire 188 | * file into memory. 189 | */ 190 | export async function fetchFile( 191 | conn: RpcProgram, 192 | file: FileInfo, 193 | onProgress?: (progress: FetchProgress) => void, 194 | span?: Span 195 | ) { 196 | const {handle, name, size} = file; 197 | const data = Buffer.alloc(size); 198 | 199 | const tx = span?.startChild({ 200 | op: 'download', 201 | description: name, 202 | data: {size}, 203 | }); 204 | 205 | let bytesRead = 0; 206 | 207 | while (bytesRead < size) { 208 | const readArgs = new nfs.ReadArgs({ 209 | handle, 210 | offset: bytesRead, 211 | count: READ_SIZE, 212 | totalCount: 0, 213 | }); 214 | 215 | const resp = await conn.call({ 216 | procedure: nfs.Procedure.read().value, 217 | data: readArgs.toXDR(), 218 | }); 219 | 220 | const dataResp = nfs.ReadResponse.fromXDR(resp); 221 | if (dataResp.arm() !== 'success') { 222 | throw new Error(`Failed to read file at offset ${bytesRead} / ${size}`); 223 | } 224 | 225 | const buffer = dataResp.success().data(); 226 | 227 | data.set(buffer, bytesRead); 228 | bytesRead += buffer.length; 229 | 230 | onProgress?.({read: bytesRead, total: size}); 231 | } 232 | 233 | tx?.finish(); 234 | 235 | return data; 236 | } 237 | -------------------------------------------------------------------------------- /src/virtualcdj/index.ts: -------------------------------------------------------------------------------- 1 | import * as ip from 'ip-address'; 2 | 3 | import {Socket} from 'dgram'; 4 | import {NetworkInterfaceInfoIPv4} from 'os'; 5 | 6 | import { 7 | ANNOUNCE_INTERVAL, 8 | ANNOUNCE_PORT, 9 | PROLINK_HEADER, 10 | VIRTUAL_CDJ_FIRMWARE, 11 | VIRTUAL_CDJ_NAME, 12 | } from 'src/constants'; 13 | import DeviceManager from 'src/devices'; 14 | import {Device, DeviceID, DeviceType} from 'src/types'; 15 | import {buildName} from 'src/utils'; 16 | 17 | /** 18 | * Constructs a virtual CDJ Device. 19 | */ 20 | export const getVirtualCDJ = (iface: NetworkInterfaceInfoIPv4, id: DeviceID): Device => ({ 21 | id, 22 | name: VIRTUAL_CDJ_NAME, 23 | type: DeviceType.CDJ, 24 | ip: new ip.Address4(iface.address), 25 | macAddr: new Uint8Array(iface.mac.split(':').map(s => parseInt(s, 16))), 26 | }); 27 | 28 | /** 29 | * Returns a mostly empty-state status packet. This is currently used to report 30 | * the virtual CDJs status, which *seems* to be required for the CDJ to send 31 | * metadata about some unanalyzed mp3 files. 32 | */ 33 | export function makeStatusPacket(device: Device): Uint8Array { 34 | // NOTE: It seems that byte 0x68 and 0x75 MUST be 1 in order for the CDJ to 35 | // correctly report mp3 metadata (again, only for some files). 36 | // See https://github.com/brunchboy/dysentery/issues/15 37 | // NOTE: Byte 0xb6 MUST be 1 in order for the CDJ to not think that our 38 | // device is "running an older firmware" 39 | // 40 | // prettier-ignore 41 | const b = new Uint8Array([ 42 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 43 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 44 | 0x03, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 45 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 46 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 47 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 48 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 49 | 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 50 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9c, 0xff, 0xfe, 0x00, 0x10, 0x00, 0x00, 51 | 0x7f, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 52 | 0xff, 0xff, 0xff, 0xff, 0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 53 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 54 | 0x00, 0x10, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 55 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 56 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 57 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 58 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 59 | 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 60 | ]); 61 | 62 | // The following items get replaced in this format: 63 | // 64 | // - 0x00: 10 byte header 65 | // - 0x0B: 20 byte device name 66 | // - 0x21: 01 byte device ID 67 | // - 0x24: 01 byte device ID 68 | // - 0x7C: 04 byte firmware string 69 | 70 | b.set(PROLINK_HEADER, 0x0b); 71 | b.set(Buffer.from(device.name, 'ascii'), 0x0b); 72 | b.set(new Uint8Array([device.id]), 0x21); 73 | b.set(new Uint8Array([device.id]), 0x24); 74 | b.set(Buffer.from(VIRTUAL_CDJ_FIRMWARE, 'ascii'), 0x7c); 75 | 76 | return b; 77 | } 78 | 79 | /** 80 | * constructs the announce packet that is sent on the prolink network to 81 | * announce a devices existence. 82 | */ 83 | export function makeAnnouncePacket(deviceToAnnounce: Device): Uint8Array { 84 | const d = deviceToAnnounce; 85 | 86 | // unknown padding bytes 87 | const unknown1 = [0x01, 0x02]; 88 | const unknown2 = [0x01, 0x00, 0x00, 0x00]; 89 | 90 | // The packet blow is constructed in the following format: 91 | // 92 | // - 0x00: 10 byte header 93 | // - 0x0A: 02 byte announce packet type 94 | // - 0x0c: 20 byte device name 95 | // - 0x20: 02 byte unknown 96 | // - 0x22: 02 byte packet length 97 | // - 0x24: 01 byte for the player ID 98 | // - 0x25: 01 byte for the player type 99 | // - 0x26: 06 byte mac address 100 | // - 0x2C: 04 byte IP address 101 | // - 0x30: 04 byte unknown 102 | // - 0x34: 01 byte for the player type 103 | // - 0x35: 01 byte final padding 104 | 105 | const parts = [ 106 | ...PROLINK_HEADER, 107 | ...[0x06, 0x00], 108 | ...buildName(d), 109 | ...unknown1, 110 | ...[0x00, 0x36], 111 | ...[d.id], 112 | ...[d.type], 113 | ...d.macAddr, 114 | ...d.ip.toArray(), 115 | ...unknown2, 116 | ...[d.type], 117 | ...[0x00], 118 | ]; 119 | 120 | return Uint8Array.from(parts); 121 | } 122 | 123 | /** 124 | * the announcer service is used to report our fake CDJ to the prolink network, 125 | * as if it was a real CDJ. 126 | */ 127 | export class Announcer { 128 | /** 129 | * The announce socket to use to make the announcements 130 | */ 131 | #announceSocket: Socket; 132 | /** 133 | * The device manager service used to determine which devices to announce 134 | * ourselves to. 135 | */ 136 | #deviceManager: DeviceManager; 137 | /** 138 | * The virtual CDJ device to announce 139 | */ 140 | #vcdj: Device; 141 | /** 142 | * The interval handle used to stop announcing 143 | */ 144 | #intervalHandle?: NodeJS.Timeout; 145 | 146 | constructor(vcdj: Device, announceSocket: Socket, deviceManager: DeviceManager) { 147 | this.#vcdj = vcdj; 148 | this.#announceSocket = announceSocket; 149 | this.#deviceManager = deviceManager; 150 | } 151 | 152 | start() { 153 | const announcePacket = makeAnnouncePacket(this.#vcdj); 154 | 155 | const announceToDevice = (device: Device) => 156 | this.#announceSocket.send(announcePacket, ANNOUNCE_PORT, device.ip.address); 157 | 158 | this.#intervalHandle = setInterval( 159 | () => [...this.#deviceManager.devices.values()].forEach(announceToDevice), 160 | ANNOUNCE_INTERVAL 161 | ); 162 | } 163 | 164 | stop() { 165 | if (this.#intervalHandle !== undefined) { 166 | clearInterval(this.#intervalHandle); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/nfs/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {Span} from '@sentry/tracing'; 3 | 4 | import {Device, DeviceID, MediaSlot} from 'src/types'; 5 | import {getSlotName} from 'src/utils'; 6 | 7 | import { 8 | fetchFile as fetchFileCall, 9 | FileInfo, 10 | getExports, 11 | lookupPath, 12 | makeProgramClient, 13 | mountFilesystem, 14 | } from './programs'; 15 | import {RetryConfig, RpcConnection, RpcProgram} from './rpc'; 16 | import {mount, nfs} from './xdr'; 17 | 18 | export interface FetchProgress { 19 | read: number; 20 | total: number; 21 | } 22 | 23 | interface ClientSet { 24 | conn: RpcConnection; 25 | mountClient: RpcProgram; 26 | nfsClient: RpcProgram; 27 | } 28 | 29 | /** 30 | * The slot <-> mount name mapping is well known. 31 | */ 32 | const slotMountMapping = { 33 | [MediaSlot.USB]: '/C/', 34 | [MediaSlot.SD]: '/B/', 35 | [MediaSlot.RB]: '/', 36 | } as const; 37 | 38 | /** 39 | * The module-level retry configuration for newly created RpcConnections. 40 | */ 41 | let retryConfig: RetryConfig = {}; 42 | 43 | /** 44 | * This module maintains a singleton cached list of player addresses -> active 45 | * connections. It is not guaranteed that the connections in the cache will 46 | * still be connected. 47 | */ 48 | const clientsCache = new Map(); 49 | 50 | /** 51 | * Given a device address running a nfs and mountd RPC server, provide 52 | * RpcProgram clients that may be used to call these services. 53 | * 54 | * NOTE: This function will cache the clients for the address, recreating the 55 | * connections if the cached clients have disconnected. 56 | */ 57 | async function getClients(address: string) { 58 | const cachedSet = clientsCache.get(address); 59 | 60 | if (cachedSet !== undefined && cachedSet.conn.connected) { 61 | return cachedSet; 62 | } 63 | 64 | // Cached socket is no longer connected. Remove and reconnect 65 | if (cachedSet !== undefined) { 66 | clientsCache.delete(address); 67 | } 68 | 69 | const conn = new RpcConnection(address, retryConfig); 70 | 71 | const mountClient = await makeProgramClient(conn, { 72 | id: mount.Program, 73 | version: mount.Version, 74 | }); 75 | 76 | const nfsClient = await makeProgramClient(conn, { 77 | id: nfs.Program, 78 | version: nfs.Version, 79 | }); 80 | 81 | const set = {conn, mountClient, nfsClient}; 82 | clientsCache.set(address, set); 83 | 84 | return set; 85 | } 86 | 87 | interface GetRootHandleOptions { 88 | device: Device; 89 | slot: keyof typeof slotMountMapping; 90 | mountClient: RpcProgram; 91 | span?: Span; 92 | } 93 | 94 | /** 95 | * This module maintains a singleton cached list of (device address + slot) -> file 96 | * handles. The file handles may become stale in this list should the devices 97 | * connected to the players slot change. 98 | */ 99 | const rootHandleCache = new Map>(); 100 | 101 | /** 102 | * Locate the root filehandle of the given device slot. 103 | * 104 | * NOTE: This function will cache the root handle for the device + slot. Should 105 | * the device have changed the slot will not longer be valid (TODO, 106 | * verify this). It is up to the caller to clear the cache and get the 107 | * new root handle in that case. 108 | */ 109 | async function getRootHandle({device, slot, mountClient, span}: GetRootHandleOptions) { 110 | const tx = span?.startChild({op: 'getRootHandle'}); 111 | 112 | const {address} = device.ip; 113 | 114 | const deviceSlotCache = rootHandleCache.get(address) ?? new Map(); 115 | const cachedRootHandle = deviceSlotCache.get(slot); 116 | 117 | if (cachedRootHandle !== undefined) { 118 | return cachedRootHandle; 119 | } 120 | 121 | const exports = await getExports(mountClient, tx); 122 | const targetExport = exports.find(e => e.filesystem === slotMountMapping[slot]); 123 | 124 | if (targetExport === undefined) { 125 | return null; 126 | } 127 | 128 | const rootHandle = await mountFilesystem(mountClient, targetExport, tx); 129 | 130 | deviceSlotCache.set(slot, rootHandle); 131 | rootHandleCache.set(address, deviceSlotCache); 132 | 133 | tx?.finish(); 134 | 135 | return rootHandle; 136 | } 137 | 138 | interface FetchFileOptions { 139 | device: Device; 140 | slot: keyof typeof slotMountMapping; 141 | path: string; 142 | onProgress?: Parameters[2]; 143 | span?: Span; 144 | } 145 | 146 | const badRoothandleError = (slot: MediaSlot, deviceId: DeviceID) => 147 | new Error(`The slot (${slot}) is not exported on Device ${deviceId}`); 148 | 149 | /** 150 | * Fetch a file from a devices NFS server. 151 | * 152 | * NOTE: The connection and root filehandle (The 'mounted' NFS export on the 153 | * device) is cached to improve subsequent fetching performance. It's 154 | * important that when the device disconnects you call the {@link 155 | * resetDeviceCache} function. 156 | */ 157 | export async function fetchFile({ 158 | device, 159 | slot, 160 | path, 161 | onProgress, 162 | span, 163 | }: FetchFileOptions) { 164 | const tx = span 165 | ? span.startChild({op: 'fetchFile'}) 166 | : Sentry.startTransaction({name: 'fetchFile'}); 167 | 168 | const {mountClient, nfsClient} = await getClients(device.ip.address); 169 | const rootHandle = await getRootHandle({device, slot, mountClient, span: tx}); 170 | 171 | if (rootHandle === null) { 172 | throw badRoothandleError(slot, device.id); 173 | } 174 | 175 | // It's possible that our roothandle is no longer valid, if we fail to lookup 176 | // a path lets first try and clear our roothandle cache 177 | let fileInfo: FileInfo | null = null; 178 | 179 | try { 180 | fileInfo = await lookupPath(nfsClient, rootHandle, path, tx); 181 | } catch { 182 | rootHandleCache.delete(device.ip.address); 183 | const rootHandle = await getRootHandle({device, slot, mountClient, span: tx}); 184 | 185 | if (rootHandle === null) { 186 | throw badRoothandleError(slot, device.id); 187 | } 188 | 189 | // Desperately try once more to lookup the file 190 | fileInfo = await lookupPath(nfsClient, rootHandle, path, tx); 191 | } 192 | 193 | const file = await fetchFileCall(nfsClient, fileInfo, onProgress, tx); 194 | 195 | tx.setData('path', path); 196 | tx.setData('slot', getSlotName(slot)); 197 | tx.setData('size', fileInfo.size); 198 | tx.finish(); 199 | 200 | return file; 201 | } 202 | 203 | /** 204 | * Clear the cached NFS connection and root filehandle for the given device 205 | */ 206 | export function resetDeviceCache(device: Device) { 207 | clientsCache.delete(device.ip.address); 208 | rootHandleCache.delete(device.ip.address); 209 | } 210 | 211 | /** 212 | * Configure the retry strategy for making NFS calls using this module 213 | */ 214 | export function configureRetryStrategy(config: RetryConfig) { 215 | retryConfig = config; 216 | 217 | for (const client of clientsCache.values()) { 218 | client.conn.retryConfig = config; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/remotedb/message/response.ts: -------------------------------------------------------------------------------- 1 | import {makeCueLoopEntry} from 'src/localdb/utils'; 2 | import {Field} from 'src/remotedb/fields'; 3 | import {fieldsToItem} from 'src/remotedb/message/item'; 4 | import {Response} from 'src/remotedb/message/types'; 5 | import { 6 | BeatGrid, 7 | CueAndLoop, 8 | HotcueButton, 9 | WaveformDetailed, 10 | WaveformHD, 11 | WaveformPreview, 12 | } from 'src/types'; 13 | import { 14 | convertWaveformHDData, 15 | extractBitMask, 16 | extractColor, 17 | makeOffsetArray, 18 | } from 'src/utils/converters'; 19 | 20 | /** 21 | * Generic null converter, for responses with no data. 22 | */ 23 | const nullConverter = (_args: Field[]) => null; 24 | 25 | /** 26 | * Converts setup success messages, which primarily includes the number of 27 | * items available upon the next request. 28 | */ 29 | const convertSuccess = (args: Field[]) => ({ 30 | itemsAvailable: args[1].value as number, 31 | }); 32 | 33 | /** 34 | * Converts artwork to a buffer. Will be mempty for empty artwork 35 | */ 36 | const convertArtwork = (args: Field[]) => args[3].value as Buffer; 37 | 38 | /** 39 | * Converts the beat grid binary response to a BeatGrid array. 40 | */ 41 | const convertBeatGrid = (args: Field[]): BeatGrid => { 42 | const BEATGRID_START = 0x14; 43 | const data = (args[3].value as Buffer).slice(BEATGRID_START); 44 | 45 | type Count = BeatGrid[number]['count']; 46 | 47 | return makeOffsetArray(data.length, 0x10).map(byteOffset => ({ 48 | offset: data.readUInt32LE(byteOffset + 4), 49 | bpm: data.readUInt16LE(byteOffset + 2) / 100, 50 | count: data[byteOffset] as Count, 51 | })); 52 | }; 53 | 54 | /** 55 | * Converts preview waveform data 56 | */ 57 | const convertWaveformPreview = (args: Field[]): WaveformPreview => { 58 | const data = args[3].value as Buffer; 59 | 60 | // TODO: The last 100 bytes in the data array is a tiny waveform preview 61 | const PREVIEW_DATA_LEN = 800; 62 | 63 | return makeOffsetArray(PREVIEW_DATA_LEN, 0x02).map(byteOffset => ({ 64 | height: data[byteOffset], 65 | whiteness: data[byteOffset + 1] / 7, 66 | })); 67 | }; 68 | 69 | /** 70 | * Converts detailed waveform data. 71 | */ 72 | const convertWaveformDetailed = (args: Field[]): WaveformDetailed => { 73 | const data = args[3].value as Buffer; 74 | 75 | // Every byte represents one segment of the waveform, and there are 150 76 | // segments per second of audio. (These seem to correspond to 'half frames' 77 | // following the seconds in the player display.) Each byte encodes both a 78 | // color and height. 79 | // 80 | // | 7 6 5 | 4 3 2 1 0 | 81 | // [ whiteness | height ] 82 | const whitenessMask = 0b11100000; // prettier-ignore 83 | const heightMask = 0b00011111; // prettier-ignore 84 | 85 | return Array.from(data).map(b => ({ 86 | height: extractBitMask(b, heightMask), 87 | whiteness: extractColor(b, whitenessMask), 88 | })); 89 | }; 90 | 91 | /** 92 | * Converts HD waveform data. 93 | */ 94 | const convertWaveformHD = (args: Field[]): WaveformHD => { 95 | // TODO: Verify this 0x34 offset is correct 96 | const WAVEFORM_START = 0x34; 97 | const data = (args[3].value as Buffer).slice(WAVEFORM_START); 98 | 99 | // TODO: This response is also used for the HD waveform previews, however 100 | // those have a much more complex data structure. 101 | 102 | return convertWaveformHDData(data); 103 | }; 104 | 105 | /** 106 | * Converts old-style cue / loop / hotcue / hotloop data. 107 | */ 108 | const convertCueAndLoops = (args: Field[]): CueAndLoop[] => { 109 | const data = args[3].value as Buffer; 110 | 111 | return makeOffsetArray(data.length, 0x24) 112 | .map(byteOffset => { 113 | const entry = data.slice(byteOffset, byteOffset + 0x24); 114 | 115 | const isLoop = !!entry[0]; 116 | const isCue = !!entry[1]; 117 | const button = entry[2] === 0 ? false : (entry[2] as HotcueButton); 118 | 119 | const offsetInFrames = entry.readUInt32LE(0x0c); 120 | const lengthInFrames = entry.readUInt32LE(0x10) - offsetInFrames; 121 | 122 | // NOTE: The offset and length are reported as 1/150th second increments. 123 | // We convert these to milliseconds here. 124 | const offset = (offsetInFrames / 150) * 1000; 125 | const length = (lengthInFrames / 150) * 1000; 126 | 127 | return makeCueLoopEntry(isCue, isLoop, offset, length, button); 128 | }) 129 | .filter((c): c is CueAndLoop => c !== null); 130 | }; 131 | 132 | /** 133 | * Converts new-style cue / loop / hotcue / hotloop data, including labels and 134 | * colors. 135 | */ 136 | const convertAdvCueAndLoops = (args: Field[]): CueAndLoop[] => { 137 | const data = args[3].value as Buffer; 138 | const entries = []; 139 | 140 | for (let offset = 0; offset < data.length; ) { 141 | const length = data.readUInt32LE(offset); 142 | entries.push(data.slice(offset, offset + length)); 143 | offset += length; 144 | } 145 | 146 | return entries 147 | .map(entry => { 148 | // Deleted cue point 149 | if (entry[6] === 0x00) { 150 | return null; 151 | } 152 | 153 | // The layout here is minorly different from the basic cue and loops, 154 | // so we unfortunately cannot reuse that logic. 155 | const button = entry[4] === 0 ? false : (entry[4] as HotcueButton); 156 | const isCue = entry[6] === 0x01; 157 | const isLoop = entry[6] === 0x02; 158 | 159 | const offsetInFrames = entry.readUInt32LE(0x0c); 160 | const lengthInFrames = entry.readUInt32LE(0x10) - offsetInFrames; 161 | 162 | // NOTE: The offset and length are reported as 1/150th second increments. 163 | // We convert these to milliseconds here. 164 | const offset = (offsetInFrames / 150) * 1000; 165 | const length = (lengthInFrames / 150) * 1000; 166 | 167 | const basicEntry = makeCueLoopEntry(isCue, isLoop, offset, length, button); 168 | 169 | // It seems the label may not always be included, if the entry is only 0x38 170 | // bytes long, exclude color and comment 171 | if (entry.length === 0x38) { 172 | return basicEntry; 173 | } 174 | 175 | const labelByteLength = entry.readUInt16LE(0x48); 176 | const label = entry 177 | .slice(0x4a, 0x4a + labelByteLength) 178 | .slice(0, -2) 179 | .toString('utf16le'); 180 | 181 | const color = entry[0x4a + labelByteLength + 0x04]; 182 | 183 | return {...basicEntry, color, label}; 184 | }) 185 | .filter((c): c is CueAndLoop => c !== null); 186 | }; 187 | 188 | export const responseTransform = { 189 | [Response.Success]: convertSuccess, 190 | [Response.Error]: nullConverter, 191 | [Response.MenuHeader]: nullConverter, 192 | [Response.MenuFooter]: nullConverter, 193 | 194 | [Response.MenuItem]: fieldsToItem, 195 | [Response.Artwork]: convertArtwork, 196 | [Response.BeatGrid]: convertBeatGrid, 197 | [Response.CueAndLoop]: convertCueAndLoops, 198 | [Response.WaveformPreview]: convertWaveformPreview, 199 | [Response.WaveformDetailed]: convertWaveformDetailed, 200 | [Response.WaveformHD]: convertWaveformHD, 201 | [Response.AdvCueAndLoops]: convertAdvCueAndLoops, 202 | } as const; 203 | -------------------------------------------------------------------------------- /src/remotedb/fields.ts: -------------------------------------------------------------------------------- 1 | import {PromiseReadable} from 'promise-readable'; 2 | 3 | const NULL_CHAR = '\0'; 4 | 5 | /** 6 | * Field type is a leading byte that indicates what the field is. 7 | */ 8 | export enum FieldType { 9 | UInt8 = 0x0f, 10 | UInt16 = 0x10, 11 | UInt32 = 0x11, 12 | Binary = 0x14, 13 | String = 0x26, 14 | } 15 | 16 | export abstract class BaseField { 17 | // The constructor property (which is used to access the class from an 18 | // instance of it) must be set to the BaseClass object so we can access the 19 | // `.type` property. 20 | // 21 | // @see https://github.com/Microsoft/TypeScript/issues/3841#issuecomment-337560146 22 | declare ['constructor']: typeof BaseField; 23 | 24 | /** 25 | * The raw field data 26 | */ 27 | declare data: Buffer; 28 | /** 29 | * Corce the field into a buffer. This differs from reading the data 30 | * property in that it will include the field type header. 31 | */ 32 | abstract buffer: Buffer; 33 | 34 | /** 35 | * Declares the type of field this class represents 36 | */ 37 | static type: FieldType; 38 | 39 | /** 40 | * The number of bytes to read for this field. If the field is not a fixed size, 41 | * set this to a function which will receive the UInt32 value just after 42 | * reading the field type, returning the next number of bytes to read. 43 | */ 44 | static bytesToRead: number | ((reportedLength: number) => number); 45 | } 46 | 47 | export type NumberField = BaseField & { 48 | /** 49 | * The fields number value 50 | */ 51 | value: T; 52 | }; 53 | 54 | export type StringField = BaseField & { 55 | /** 56 | * The fields decoded string value 57 | */ 58 | value: T; 59 | }; 60 | 61 | export type BinaryField = BaseField & { 62 | /** 63 | * The binary value encapsulated in the field 64 | */ 65 | value: Buffer; 66 | }; 67 | 68 | export type Field = NumberField | StringField | BinaryField; 69 | 70 | type NumberFieldType = FieldType.UInt32 | FieldType.UInt16 | FieldType.UInt8; 71 | 72 | const numberNameMap = Object.fromEntries( 73 | Object.entries(FieldType).map(e => [e[1], e[0]]) 74 | ); 75 | 76 | const numberBufferInfo = { 77 | [FieldType.UInt8]: [1, 'writeUInt8', 'readUInt8'], 78 | [FieldType.UInt16]: [2, 'writeUInt16BE', 'readUInt16BE'], 79 | [FieldType.UInt32]: [4, 'writeUInt32BE', 'readUInt32BE'], 80 | } as const; 81 | 82 | function parseNumber(value: number | Buffer, type: NumberFieldType): [number, Buffer] { 83 | const [bytes, writeFn, readFn] = numberBufferInfo[type]; 84 | const data = Buffer.alloc(bytes); 85 | 86 | if (typeof value === 'number') { 87 | data[writeFn](value); 88 | return [value, data]; 89 | } 90 | 91 | return [value[readFn](), value]; 92 | } 93 | 94 | function makeVariableBuffer(type: FieldType, fieldData: Buffer, lengthHeader?: number) { 95 | // Add 4 bytes for length header and 1 byte for type header. 96 | const data = Buffer.alloc(fieldData.length + 4 + 1); 97 | data.writeUInt8(type); 98 | data.writeUInt32BE(lengthHeader ?? fieldData.length, 0x01); 99 | 100 | fieldData.copy(data, 0x05); 101 | 102 | return data; 103 | } 104 | 105 | const makeNumberField = (type: NumberFieldType) => { 106 | const Number = class extends BaseField implements NumberField { 107 | static type = type; 108 | static bytesToRead = numberBufferInfo[type][0]; 109 | 110 | value: number; 111 | 112 | constructor(value: number | Buffer) { 113 | super(); 114 | const [number, data] = parseNumber(value, type); 115 | this.data = data; 116 | this.value = number; 117 | } 118 | 119 | get buffer() { 120 | return Buffer.from([type, ...this.data]); 121 | } 122 | }; 123 | 124 | // We use the name property in readField to create helpful error messages 125 | Object.defineProperty(Number, 'name', {value: numberNameMap[type]}); 126 | 127 | return Number; 128 | }; 129 | 130 | /** 131 | * Field representing a UInt8 132 | */ 133 | export const UInt8 = makeNumberField(FieldType.UInt8); 134 | 135 | /** 136 | * Field representing a UInt16 137 | */ 138 | export const UInt16 = makeNumberField(FieldType.UInt16); 139 | 140 | /** 141 | * Field representing a UInt32 142 | */ 143 | export const UInt32 = makeNumberField(FieldType.UInt32); 144 | 145 | /** 146 | * Field representing a null-terminated big endian UTF-16 string 147 | */ 148 | export class String extends BaseField implements StringField { 149 | static type = FieldType.String as const; 150 | 151 | // Compute the number of bytes in the string given the length of the string. 152 | // A UTF-16 string takes 2 bytes per character. 153 | static bytesToRead = (length: number) => length * 2; 154 | 155 | value: string; 156 | 157 | constructor(value: Buffer | string) { 158 | super(); 159 | if (typeof value === 'string') { 160 | this.value = value; 161 | this.data = Buffer.from(value + NULL_CHAR, 'utf16le').swap16(); 162 | return; 163 | } 164 | 165 | // Slice off the last two bytes to remove the trailing null bytes 166 | this.value = Buffer.from(value).swap16().slice(0, -2).toString('utf16le'); 167 | this.data = value; 168 | } 169 | 170 | get buffer() { 171 | return makeVariableBuffer(FieldType.String, this.data, this.data.length / 2); 172 | } 173 | } 174 | 175 | /** 176 | * Field representing binary data 177 | */ 178 | export class Binary extends BaseField implements BinaryField { 179 | static type = FieldType.Binary as const; 180 | static bytesToRead = (bytes: number) => bytes; 181 | 182 | value: Buffer; 183 | 184 | constructor(value: Buffer) { 185 | super(); 186 | this.value = this.data = value; 187 | } 188 | 189 | get buffer() { 190 | return makeVariableBuffer(FieldType.Binary, this.data); 191 | } 192 | } 193 | 194 | const fieldMap = { 195 | [FieldType.UInt8]: UInt8, 196 | [FieldType.UInt16]: UInt16, 197 | [FieldType.UInt32]: UInt32, 198 | [FieldType.Binary]: Binary, 199 | [FieldType.String]: String, 200 | } as const; 201 | 202 | /** 203 | * Helper to read from stream. 204 | * 205 | * NOTE: I suspect the typescript interface on PromiseReadable may be wrong, as 206 | * I'm not sure when this would return a string. We'll play it safe for now. 207 | */ 208 | async function read(stream: PromiseReadable, bytes: number) { 209 | const data = await stream.read(bytes); 210 | 211 | if (data instanceof Buffer) { 212 | return data; 213 | } 214 | 215 | throw new Error('Expected buffer from stream read'); 216 | } 217 | 218 | /** 219 | * Read a single field from a socket stream. 220 | */ 221 | export async function readField< 222 | T extends FieldType, 223 | F extends InstanceType<(typeof fieldMap)[T]>, 224 | >(stream: PromiseReadable, expect: T): Promise { 225 | const typeData = await read(stream, 1); 226 | const Field = fieldMap[typeData[0] as FieldType]; 227 | 228 | if (Field.type !== expect) { 229 | throw new Error(`Expected ${fieldMap[expect].name} but got ${Field.name}`); 230 | } 231 | 232 | let nextByteCount: number; 233 | 234 | if (typeof Field.bytesToRead === 'number') { 235 | nextByteCount = Field.bytesToRead; 236 | } else { 237 | // Read the field length as a UInt32 when we do not know the field length 238 | // from the type 239 | const lengthData = await read(stream, 4); 240 | nextByteCount = Field.bytesToRead(lengthData.readUInt32BE()); 241 | } 242 | 243 | const data = nextByteCount === 0 ? Buffer.alloc(0) : await read(stream, nextByteCount); 244 | 245 | return new Field(data) as F; 246 | } 247 | -------------------------------------------------------------------------------- /src/remotedb/message/item.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | 3 | import {Field, NumberField, StringField} from 'src/remotedb/fields'; 4 | 5 | /** 6 | * Item types associated to the MenuItem message type. 7 | */ 8 | export enum ItemType { 9 | Path = 0x0000, 10 | Folder = 0x0001, 11 | AlbumTitle = 0x0002, 12 | Disc = 0x0003, 13 | TrackTitle = 0x0004, 14 | Genre = 0x0006, 15 | Artist = 0x0007, 16 | Playlist = 0x0008, 17 | Rating = 0x000a, 18 | Duration = 0x000b, 19 | Tempo = 0x000d, 20 | Label = 0x000e, 21 | Key = 0x000f, 22 | BitRate = 0x0010, 23 | Year = 0x0011, 24 | Comment = 0x0023, 25 | HistoryPlaylist = 0x0024, 26 | OriginalArtist = 0x0028, 27 | Remixer = 0x0029, 28 | DateAdded = 0x002e, 29 | Unknown01 = 0x002f, 30 | Unknown02 = 0x002a, 31 | 32 | ColorNone = 0x0013, 33 | ColorPink = 0x0014, 34 | ColorRed = 0x0015, 35 | ColorOrange = 0x0016, 36 | ColorYellow = 0x0017, 37 | ColorGreen = 0x0018, 38 | ColorAqua = 0x0019, 39 | ColorBlue = 0x001a, 40 | ColorPurple = 0x001b, 41 | 42 | MenuGenre = 0x0080, 43 | MenuArtist = 0x0081, 44 | MenuAlbum = 0x0082, 45 | MenuTrack = 0x0083, 46 | MenuPlaylist = 0x0084, 47 | MenuBPM = 0x0085, 48 | MenuRating = 0x0086, 49 | MenuYear = 0x0087, 50 | MenuRemixer = 0x0088, 51 | MenuLabel = 0x0089, 52 | MenuOriginal = 0x008a, 53 | MenuKey = 0x008b, 54 | MenuColor = 0x008e, 55 | MenuFolder = 0x0090, 56 | MenuSearch = 0x0091, 57 | MenuTime = 0x0092, 58 | MenuBit = 0x0093, 59 | MenuFilename = 0x0094, 60 | MenuHistory = 0x0095, 61 | MenuAll = 0x00a0, 62 | 63 | TrackTitleAlbum = 0x0204, 64 | TrackTitleGenre = 0x0604, 65 | TrackTitleArtist = 0x0704, 66 | TrackTitleRating = 0x0a04, 67 | TrackTitleTime = 0x0b04, 68 | TrackTitleBPM = 0x0d04, 69 | TrackTitleLabel = 0x0e04, 70 | TrackTitleKey = 0x0f04, 71 | TrackTitleBitRate = 0x1004, 72 | TrackTitleColor = 0x1a04, 73 | TrackTitleComment = 0x2304, 74 | TrackTitleOriginalArtist = 0x2804, 75 | TrackTitleRemixer = 0x2904, 76 | TrackTitleDJPlayCount = 0x2a04, 77 | MenuTrackTitleDateAdded = 0x2e04, 78 | } 79 | 80 | /** 81 | * All items have 12 arguments of these types 82 | */ 83 | type ItemArgs = [ 84 | NumberField, // Parent ID, such as an artist for a track item. 85 | NumberField, // Main ID, such as rekordbox for a track item. 86 | NumberField, // Length in bytes of Label 1. 87 | StringField, // Label 1 main text 88 | NumberField, // Length in bytes of Label 2. 89 | StringField, // Label 2 (secondary text, e.g. artist name for playlist entries) 90 | NumberField, 91 | NumberField, // Some type of flags 92 | NumberField, // Only holds artwork ID? 93 | NumberField, 94 | NumberField, 95 | NumberField, 96 | ]; 97 | 98 | /** 99 | * Convert a message item argument lists to a structured intermediate object 100 | * for more clear access. 101 | */ 102 | const makeItemData = (args: ItemArgs) => ({ 103 | parentId: args[0].value, 104 | mainId: args[1].value, 105 | label1: args[3].value, 106 | label2: args[5].value, 107 | type: args[6].value, 108 | artworkId: args[8].value, 109 | }); 110 | 111 | type ItemData = ReturnType; 112 | 113 | /** 114 | * Generic transformer for items that include just an id and label 115 | */ 116 | const mapIdName = (a: ItemData) => ({ 117 | id: a.mainId, 118 | name: a.label1, 119 | }); 120 | 121 | /** 122 | * Maps item types to structured objects 123 | */ 124 | const transformItem = { 125 | [ItemType.Path]: (a: ItemData) => ({path: a.label1}), 126 | [ItemType.TrackTitle]: (a: ItemData) => ({ 127 | id: a.mainId, 128 | title: a.label1, 129 | artworkId: a.artworkId, 130 | }), 131 | [ItemType.AlbumTitle]: mapIdName, 132 | [ItemType.Artist]: mapIdName, 133 | [ItemType.Genre]: mapIdName, 134 | [ItemType.Label]: mapIdName, 135 | [ItemType.Key]: mapIdName, 136 | [ItemType.OriginalArtist]: mapIdName, 137 | [ItemType.Remixer]: mapIdName, 138 | [ItemType.BitRate]: (a: ItemData) => ({bitrate: a.mainId}), 139 | [ItemType.Comment]: (a: ItemData) => ({comment: a.label1}), 140 | [ItemType.Year]: (a: ItemData) => ({year: Number(a.label1)}), 141 | [ItemType.Rating]: (a: ItemData) => ({rating: a.mainId}), 142 | [ItemType.Tempo]: (a: ItemData) => ({bpm: a.mainId / 100}), 143 | [ItemType.Duration]: (a: ItemData) => ({duration: a.mainId}), 144 | [ItemType.Unknown01]: (_: ItemData) => null, 145 | [ItemType.Unknown02]: (_: ItemData) => null, 146 | 147 | [ItemType.ColorNone]: mapIdName, 148 | [ItemType.ColorPink]: mapIdName, 149 | [ItemType.ColorRed]: mapIdName, 150 | [ItemType.ColorOrange]: mapIdName, 151 | [ItemType.ColorYellow]: mapIdName, 152 | [ItemType.ColorGreen]: mapIdName, 153 | [ItemType.ColorAqua]: mapIdName, 154 | [ItemType.ColorBlue]: mapIdName, 155 | [ItemType.ColorPurple]: mapIdName, 156 | 157 | [ItemType.Folder]: mapIdName, 158 | [ItemType.Playlist]: mapIdName, 159 | 160 | // TODO: All of these item types are missing 161 | [ItemType.Disc]: (a: ItemData) => a, 162 | 163 | [ItemType.HistoryPlaylist]: (a: ItemData) => a, 164 | [ItemType.DateAdded]: (a: ItemData) => a, 165 | [ItemType.MenuGenre]: (a: ItemData) => a, 166 | [ItemType.MenuArtist]: (a: ItemData) => a, 167 | [ItemType.MenuAlbum]: (a: ItemData) => a, 168 | [ItemType.MenuTrack]: (a: ItemData) => a, 169 | [ItemType.MenuPlaylist]: (a: ItemData) => a, 170 | [ItemType.MenuBPM]: (a: ItemData) => a, 171 | [ItemType.MenuRating]: (a: ItemData) => a, 172 | [ItemType.MenuYear]: (a: ItemData) => a, 173 | [ItemType.MenuRemixer]: (a: ItemData) => a, 174 | [ItemType.MenuLabel]: (a: ItemData) => a, 175 | [ItemType.MenuOriginal]: (a: ItemData) => a, 176 | [ItemType.MenuKey]: (a: ItemData) => a, 177 | [ItemType.MenuColor]: (a: ItemData) => a, 178 | [ItemType.MenuFolder]: (a: ItemData) => a, 179 | [ItemType.MenuSearch]: (a: ItemData) => a, 180 | [ItemType.MenuTime]: (a: ItemData) => a, 181 | [ItemType.MenuBit]: (a: ItemData) => a, 182 | [ItemType.MenuFilename]: (a: ItemData) => a, 183 | [ItemType.MenuHistory]: (a: ItemData) => a, 184 | [ItemType.MenuAll]: (a: ItemData) => a, 185 | [ItemType.TrackTitleAlbum]: (a: ItemData) => a, 186 | [ItemType.TrackTitleGenre]: (a: ItemData) => a, 187 | [ItemType.TrackTitleArtist]: (a: ItemData) => a, 188 | [ItemType.TrackTitleRating]: (a: ItemData) => a, 189 | [ItemType.TrackTitleTime]: (a: ItemData) => a, 190 | [ItemType.TrackTitleBPM]: (a: ItemData) => a, 191 | [ItemType.TrackTitleLabel]: (a: ItemData) => a, 192 | [ItemType.TrackTitleKey]: (a: ItemData) => a, 193 | [ItemType.TrackTitleBitRate]: (a: ItemData) => a, 194 | [ItemType.TrackTitleColor]: (a: ItemData) => a, 195 | [ItemType.TrackTitleComment]: (a: ItemData) => a, 196 | [ItemType.TrackTitleOriginalArtist]: (a: ItemData) => a, 197 | [ItemType.TrackTitleRemixer]: (a: ItemData) => a, 198 | [ItemType.TrackTitleDJPlayCount]: (a: ItemData) => a, 199 | [ItemType.MenuTrackTitleDateAdded]: (a: ItemData) => a, 200 | }; 201 | 202 | /** 203 | * Represents a generic Item, specialized to a specific item by providing a 204 | * ItemType to the template. 205 | */ 206 | export type Item = ReturnType<(typeof transformItem)[T]> & {type: T}; 207 | 208 | /** 209 | * Maps ItemTypes to Items 210 | */ 211 | export type Items = { 212 | [T in keyof typeof transformItem]: Item; 213 | }; 214 | 215 | /** 216 | * Translate a list of fields for an item response into a structure object, 217 | * making items more clear to work with. 218 | */ 219 | export const fieldsToItem = (args: Field[]) => { 220 | const itemData = makeItemData(args as ItemArgs); 221 | const {type} = itemData; 222 | 223 | let transformer = transformItem[type]; 224 | 225 | // Typescript gives us safety, but it is possible there is an itemType we're 226 | // not aware of yet. 227 | if (transformer === undefined) { 228 | transformer = () => null; 229 | 230 | Sentry.captureMessage( 231 | `No item transformer registered for item type ${type}`, 232 | Sentry.Severity.Error 233 | ); 234 | } 235 | 236 | return {...transformer(itemData), type} as Items[ItemType]; 237 | }; 238 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to prolink-connect will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | - Bumped many pacakges 11 | - Require Node 20 minimum version 12 | - Fixed a typo in `ItemType.OriginalArtist` (was mispelled as `OrigianlArtist`) 13 | - Small comment correction for vCDJ ID on `autoconfigFromPeers` 14 | 15 | ## [v0.11.0] - 2022-10-24 16 | 17 | - Package updates. No changes 18 | 19 | ## [v0.10.0] - 2021-05-23 20 | 21 | ### Changed 22 | 23 | - Switch to using Player ID 7 for the virtual CDJ. Freeing up slot 5 for 24 | CDJ-3000s. 25 | 26 | ## [v0.9.0] - 2021-05-18 27 | 28 | ### Added 29 | 30 | - You can now call `network.db.getPlaylist` to receive the listing for a 31 | playlist. Without specifying the playlist to lookup the root playlist will be 32 | queried. 33 | 34 | ### Fixed 35 | 36 | - Remote database calls could fail for requests that result in a large number 37 | of rows. Unless you were using the remotedb query interface directly, it is 38 | unlikely you would have ran into this problem. The two implemented queries do 39 | not return enough rows to result in the error. 40 | 41 | ## [v0.8.1] - 2021-04-23 42 | 43 | ### Changed 44 | 45 | - Bumped to latest js-xdr to remove node Buffer warnings. 46 | 47 | ## [v0.8.0] - 2021-04-12 48 | 49 | ### Added 50 | 51 | - You can now call `network.db.getWaveforms` to load waveforms for a track. 52 | 53 | - The `isEmergencyMode` flag has been added to the CDJStatus type. This reports 54 | if the CDJ is in an emergency loop (or just emergency mode in newer players) 55 | 56 | ## [v0.7.2] - 2021-02-15 57 | 58 | ### Fixed 59 | 60 | - Do not import the mixstatus module in the types export, as this exports more 61 | things that we really don't want. 62 | 63 | ## [v0.7.1] - 2021-02-15 64 | 65 | ### Fixed 66 | 67 | - Actually export `MixstatusMode`, not just the type. 68 | 69 | ## [v0.7.0] - 2021-02-14 70 | 71 | ### Changed 72 | 73 | - `ReportingMode` has been renamed to `MixstatusMode` and is now exported in 74 | `prolink-connect/lib/types`. 75 | 76 | ## [v0.6.0] - 2021-02-14 77 | 78 | ### Added 79 | 80 | - A new `triggerNextTrack` method has been introduced to the Mixstatus service. 81 | Calling this will immediately report the player which has been playing for 82 | the longest as now playing. 83 | 84 | - the Mixstatus service has learned to follow master. See the changes to 85 | Mixstatus below. 86 | 87 | ### Changed 88 | 89 | - The Mixstatus service's configuration has been restructured and has learned 90 | how to follow master. 91 | 92 | - `reportRequiresSilence` has been removed 93 | 94 | - A new `mode` option has been introduced that configures how the mixstatus 95 | processor will generally determine when a track change has happened. The 96 | `ReportingMode` defines: `SmartTiming` (the default), `WaitsForSilence` 97 | (the replacement for `reportRequiresSilence`), and a new `FollowsMaster` 98 | mode, which simply causes tracks to be reported when the player becomes 99 | master (assuming it is on air and playing). 100 | 101 | ## [v0.5.0] - 2021-02-01 102 | 103 | ### Fixed 104 | 105 | - Binding to the detected interface to broadcast the announcement packets is not 106 | the best approach, since we then can no longer receive broadcast packets. 107 | Instead, we can just announce to all connected devices on each announcement 108 | tick. 109 | 110 | ### Changed 111 | 112 | - Upgraded to latest Kaitai struct definitions for rekordbox database decoding. 113 | Thank you [@brunchboy](https://github.com/brunchboy). 114 | 115 | ## [v0.4.0] - 2021-02-01 116 | 117 | ### Fixed 118 | 119 | - Bind announcement to the configured interface. This corrects an issue where 120 | prolink connect could fail to correctly connect to the CDJs when the OS's 121 | routing table did not correctly route the announce broadcast packets. 122 | 123 | - Disconnect all sockets when calling `disconnect` on the network. 124 | 125 | ## [v0.3.0] - 2020-12-03 126 | 127 | ### Added 128 | 129 | - Allow the mixstatus processor to be configured. 130 | 131 | ## [v0.2.0] - 2020-11-18 132 | 133 | ### Added 134 | 135 | - Introduced a method to play and cue CDJs. 136 | 137 | - Device manager has learned `getDeviceEnsured`, which will wait until the 138 | device appears on the network before resolving. Useful for when you know a 139 | device should be on the network, but maybe has not yet announced itself 140 | 141 | - Use `getDeviceEnsured` when querying the aggregate database. This will help 142 | with situations where a device reports having a track loaded from a device 143 | which has not yet announced itself on the network. 144 | 145 | - A new `prolink-connect/lib/types` file is available, which only exports types 146 | and enums, and does NOT require any runtime dependencies. This may be useful 147 | when you want to use prolink-connect types in a frontend application, and do 148 | not want to accidentally bundle various node.js dependencies into your app. 149 | 150 | This specifically will fix an issue where `@sentry/node` was being bundled 151 | into frontend apps. 152 | 153 | ### Changed 154 | 155 | - Expose the mixstatus processor as a service getter on the Network object. 156 | This makes it easier to share a single instance of the mixstatus processor 157 | within an app. 158 | 159 | - Remove the `mikro-orm` dependency. We now directly use SQLite to cache pdb 160 | databases locally. 161 | 162 | ### Fixed 163 | 164 | - Fixed various false-positive now-playing repostings with the mixstatus 165 | processor, along with some missing now-playing events. 166 | 167 | This removes a _huge_ dependency from the library, and makes consumption 168 | significantly easier if you plan to bundle your application. 169 | 170 | There should be no API changes because of this. 171 | 172 | - Fixed a minor bug in trackTypeNames mapping. 173 | 174 | - Avoid hard errors on failed artwork lookups. 175 | 176 | ## [v0.1.0] - 2020-06-23 177 | 178 | ### Fixed 179 | 180 | - Fixed a bug in mixstatus when ending a track by taking the deck off-air and 181 | then cueing before it finished determining if the off-air action passed the 182 | number of interrupt beats, causing the track to incorrectly NOT be cleared 183 | from having been marked as having reported itself as playing. 184 | 185 | ## [v0.1.0-prerelease.21] - 2020-06-17 186 | 187 | ### Added 188 | 189 | - Initial working implementation. This is currently being used to re-implement 190 | [prolink-tools](https://github.com/evanpurkhiser/prolink-tools). 191 | 192 | [unreleased]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.11.0...HEAD 193 | [v0.11.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.10.0...v0.11.0 194 | [v0.10.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.9.0...v0.10.0 195 | [v0.9.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.8.1...v0.9.0 196 | [v0.8.1]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.8.0...v0.8.1 197 | [v0.8.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.7.2...v0.8.0 198 | [v0.7.2]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.7.1...v0.7.2 199 | [v0.7.1]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.7.0...v0.7.1 200 | [v0.7.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.6.0...v0.7.0 201 | [v0.6.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.5.0...v0.6.0 202 | [v0.5.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.4.0...v0.5.0 203 | [v0.4.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.3.0...v0.4.0 204 | [v0.3.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.2.0...v0.3.0 205 | [v0.2.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.1.0...v0.2.0 206 | [v0.1.0]: https://github.com/evanpurkhiser/prolink-connect/compare/v0.1.0-prerelease.21...v0.1.0 207 | [v0.1.0-prerelease.21]: https://github.com/evanpurkhiser/prolink-connect/compare/ef4b95d...v0.1.0-prerelease.21 208 | -------------------------------------------------------------------------------- /src/nfs/xdr.ts: -------------------------------------------------------------------------------- 1 | import * as XDR from 'js-xdr'; 2 | import {calculatePadding, slicePadding} from 'js-xdr/lib/util'; 3 | 4 | /** 5 | * A xdr type to read the rest of the data in the buffer 6 | */ 7 | const OpaqueData = { 8 | read(io: any) { 9 | return io.slice().buffer(); 10 | }, 11 | 12 | write(value: any, io: any) { 13 | io.writeBufferPadded(value); 14 | }, 15 | 16 | isValid(value: any) { 17 | return Buffer.isBuffer(value); 18 | }, 19 | }; 20 | 21 | /** 22 | * In the standard NFS protocol,strings are typically ASCII. For Pioneer 23 | * players, it is an UTF-16LE encoded string; This type handles conversion. 24 | */ 25 | class StringUTF16LE { 26 | read(io: any) { 27 | const length = XDR.Int.read(io); 28 | const padding = calculatePadding(length); 29 | const result = io.slice(length); 30 | 31 | slicePadding(io, padding); 32 | 33 | return result.buffer().toString('utf16le'); 34 | } 35 | 36 | write(value: any, io: any) { 37 | const data = Buffer.from(value, 'utf16le'); 38 | XDR.Int.write(data.length, io); 39 | io.writeBufferPadded(data); 40 | } 41 | 42 | isValid(value: any) { 43 | return typeof value === 'string'; 44 | } 45 | } 46 | 47 | /** 48 | * RPC XDR data types. This implements nearly the entire XDR spec for the 49 | * ONC-RPC protocol. 50 | */ 51 | export const rpc = XDR.config((xdr: any) => { 52 | xdr.const('Version', 2); 53 | 54 | xdr.enum('MessageType', { 55 | request: 0, 56 | response: 1, 57 | }); 58 | 59 | xdr.enum('ResponseStatus', { 60 | accepted: 0, 61 | denied: 1, 62 | }); 63 | 64 | xdr.enum('AcceptStatus', { 65 | success: 0, 66 | programUnavailable: 1, 67 | programMismatch: 2, 68 | processUnavailable: 3, 69 | garbageArguments: 4, 70 | systemError: 5, 71 | }); 72 | 73 | xdr.enum('RejectStatus', { 74 | mismatch: 0, 75 | authError: 1, 76 | }); 77 | 78 | xdr.enum('AuthStatus', { 79 | ok: 0, 80 | badCredentials: 1, 81 | rjectedCredentials: 2, 82 | badVerification: 3, 83 | rejectedVerification: 4, 84 | tooWeak: 5, 85 | invalidResponse: 6, 86 | failed: 7, 87 | }); 88 | 89 | xdr.struct('UnixAuth', [ 90 | ['stamp', xdr.uint()], 91 | ['name', xdr.string(255)], 92 | ['uid', xdr.uint()], 93 | ['gid', xdr.uint()], 94 | ['gids', xdr.varArray(xdr.uint(), 16)], 95 | ]); 96 | 97 | xdr.struct('Auth', [ 98 | ['flavor', xdr.uint()], 99 | ['body', xdr.varOpaque(400)], 100 | ]); 101 | 102 | xdr.struct('Request', [ 103 | ['rpcVersion', xdr.uint()], 104 | ['program', xdr.uint()], 105 | ['programVersion', xdr.uint()], 106 | ['procedure', xdr.uint()], 107 | ['auth', xdr.lookup('Auth')], 108 | ['verifier', xdr.lookup('Auth')], 109 | ['data', OpaqueData], 110 | ]); 111 | 112 | xdr.struct('MismatchInfo', [ 113 | ['low', xdr.uint()], 114 | ['high', xdr.uint()], 115 | ]); 116 | 117 | xdr.union('ResponseData', { 118 | switchOn: xdr.lookup('AcceptStatus'), 119 | defaultArm: xdr.void(), 120 | switches: [ 121 | ['success', 'success'], 122 | ['programMismatch', 'programMismatch'], 123 | ], 124 | arms: { 125 | success: OpaqueData, 126 | programMismatch: xdr.lookup('MismatchInfo'), 127 | }, 128 | }); 129 | 130 | xdr.struct('AcceptedResponse', [ 131 | ['verifier', xdr.lookup('Auth')], 132 | ['response', xdr.lookup('ResponseData')], 133 | ]); 134 | 135 | xdr.union('RejectedResponse', { 136 | switchOn: xdr.lookup('RejectStatus'), 137 | switches: [ 138 | ['mismatch', 'mismatch'], 139 | ['authError', 'authError'], 140 | ], 141 | arms: { 142 | mismatch: xdr.lookup('MismatchInfo'), 143 | authError: xdr.lookup('AuthStatus'), 144 | }, 145 | }); 146 | 147 | xdr.union('Response', { 148 | switchOn: xdr.lookup('ResponseStatus'), 149 | switches: [ 150 | ['accepted', 'accepted'], 151 | ['denied', 'denied'], 152 | ], 153 | arms: { 154 | accepted: xdr.lookup('AcceptedResponse'), 155 | denied: xdr.void(), 156 | }, 157 | }); 158 | 159 | xdr.union('Message', { 160 | switchOn: xdr.lookup('MessageType'), 161 | switches: [ 162 | ['request', 'request'], 163 | ['response', 'response'], 164 | ], 165 | arms: { 166 | request: xdr.lookup('Request'), 167 | response: xdr.lookup('Response'), 168 | }, 169 | }); 170 | 171 | xdr.struct('Packet', [ 172 | ['xid', xdr.uint()], 173 | ['message', xdr.lookup('Message')], 174 | ]); 175 | }); 176 | 177 | /** 178 | * Portmap RPC XDR types 179 | */ 180 | export const portmap = XDR.config((xdr: any) => { 181 | xdr.const('Program', 100000); 182 | xdr.const('Version', 2); 183 | 184 | xdr.enum('Procedure', { 185 | getPort: 3, 186 | }); 187 | 188 | xdr.struct('GetPort', [ 189 | ['program', xdr.uint()], 190 | ['version', xdr.uint()], 191 | ['protocol', xdr.uint()], 192 | ['port', xdr.uint()], 193 | ]); 194 | }); 195 | 196 | /** 197 | * Mount RPC XDR types 198 | */ 199 | export const mount = XDR.config((xdr: any) => { 200 | xdr.const('Program', 100005); 201 | xdr.const('Version', 1); 202 | 203 | xdr.enum('Procedure', { 204 | mount: 1, 205 | export: 5, 206 | }); 207 | 208 | xdr.typedef('Path', new StringUTF16LE()); 209 | xdr.typedef('Filehandle', xdr.opaque(32)); 210 | 211 | xdr.struct('MountRequest', [['filesystem', xdr.lookup('Path')]]); 212 | 213 | xdr.struct('Groups', [ 214 | ['name', xdr.string(255)], 215 | ['next', xdr.option(xdr.lookup('Groups'))], 216 | ]); 217 | 218 | xdr.struct('ExportList', [ 219 | ['filesystem', xdr.lookup('Path')], 220 | ['groups', xdr.option(xdr.lookup('Groups'))], 221 | ['next', xdr.option(xdr.lookup('ExportList'))], 222 | ]); 223 | 224 | xdr.union('FHStatus', { 225 | switchOn: xdr.uint(), 226 | defaultArm: xdr.void(), 227 | switches: [[0, 'success']], 228 | arms: { 229 | success: xdr.lookup('Filehandle'), 230 | }, 231 | }); 232 | 233 | xdr.struct('ExportListResponse', [['next', xdr.option(xdr.lookup('ExportList'))]]); 234 | }); 235 | 236 | /** 237 | * NFS RPC XDR types 238 | */ 239 | export const nfs = XDR.config((xdr: any) => { 240 | xdr.const('Program', 100003); 241 | xdr.const('Version', 2); 242 | 243 | xdr.enum('Procedure', { 244 | lookup: 4, 245 | read: 6, 246 | }); 247 | 248 | xdr.typedef('Filename', new StringUTF16LE()); 249 | xdr.typedef('Filehandle', xdr.opaque(32)); 250 | xdr.typedef('NFSData', xdr.varOpaque(8192)); 251 | 252 | xdr.enum('FileType', { 253 | null: 0, 254 | regular: 1, 255 | directory: 2, 256 | block: 3, 257 | char: 4, 258 | link: 5, 259 | }); 260 | 261 | xdr.struct('TimeValue', [ 262 | ['seconds', xdr.uint()], 263 | ['useconds', xdr.uint()], 264 | ]); 265 | 266 | xdr.struct('FileAttributes', [ 267 | ['type', xdr.lookup('FileType')], 268 | ['mode', xdr.uint()], 269 | ['nlink', xdr.uint()], 270 | ['uid', xdr.uint()], 271 | ['gid', xdr.uint()], 272 | ['size', xdr.uint()], 273 | ['blocksize', xdr.uint()], 274 | ['rdev', xdr.uint()], 275 | ['blocks', xdr.uint()], 276 | ['fsid', xdr.uint()], 277 | ['fileid', xdr.uint()], 278 | ['atime', xdr.lookup('TimeValue')], 279 | ['mtime', xdr.lookup('TimeValue')], 280 | ['ctime', xdr.lookup('TimeValue')], 281 | ]); 282 | 283 | xdr.struct('DirectoryOpArgs', [ 284 | ['handle', xdr.lookup('Filehandle')], 285 | ['filename', xdr.lookup('Filename')], 286 | ]); 287 | 288 | xdr.struct('DirectoryOpResponseBody', [ 289 | ['handle', xdr.lookup('Filehandle')], 290 | ['attributes', xdr.lookup('FileAttributes')], 291 | ]); 292 | 293 | xdr.union('DirectoryOpResponse', { 294 | switchOn: xdr.uint(), 295 | defaultArm: xdr.void(), 296 | switches: [[0, 'success']], 297 | arms: { 298 | success: xdr.lookup('DirectoryOpResponseBody'), 299 | }, 300 | }); 301 | 302 | xdr.struct('ReadArgs', [ 303 | ['handle', xdr.lookup('Filehandle')], 304 | ['offset', xdr.uint()], 305 | ['count', xdr.uint()], 306 | ['totalCount', xdr.uint()], 307 | ]); 308 | 309 | xdr.struct('ReadBody', [ 310 | ['attributes', xdr.lookup('FileAttributes')], 311 | ['data', xdr.lookup('NFSData')], 312 | ]); 313 | 314 | xdr.union('ReadResponse', { 315 | switchOn: xdr.uint(), 316 | defaultArm: xdr.void(), 317 | switches: [[0, 'success']], 318 | arms: { 319 | success: xdr.lookup('ReadBody'), 320 | }, 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {SpanStatus} from '@sentry/tracing'; 3 | 4 | import DeviceManager from 'src/devices'; 5 | import {Track} from 'src/entities'; 6 | import LocalDatabase from 'src/localdb'; 7 | import RemoteDatabase from 'src/remotedb'; 8 | import { 9 | Device, 10 | DeviceType, 11 | MediaSlot, 12 | PlaylistContents, 13 | TrackType, 14 | Waveforms, 15 | } from 'src/types'; 16 | import {getSlotName, getTrackTypeName} from 'src/utils'; 17 | 18 | import * as GetArtwork from './getArtwork'; 19 | import * as GetMetadata from './getMetadata'; 20 | import * as GetPlaylist from './getPlaylist'; 21 | import * as GetWaveforms from './getWaveforms'; 22 | 23 | enum LookupStrategy { 24 | Remote, 25 | Local, 26 | NoneAvailable, 27 | } 28 | 29 | /** 30 | * A Database is the central service used to query devices on the prolink 31 | * network for information from their databases. 32 | */ 33 | class Database { 34 | #hostDevice: Device; 35 | #deviceManager: DeviceManager; 36 | /** 37 | * The local database service, used when querying media devices connected 38 | * directly to CDJs containing a rekordbox formatted database. 39 | */ 40 | #localDatabase: LocalDatabase; 41 | /** 42 | * The remote database service, used when querying the Rekordbox software or a 43 | * CDJ with an unanalyzed media device connected (when possible). 44 | */ 45 | #remoteDatabase: RemoteDatabase; 46 | 47 | constructor( 48 | hostDevice: Device, 49 | local: LocalDatabase, 50 | remote: RemoteDatabase, 51 | deviceManager: DeviceManager 52 | ) { 53 | this.#hostDevice = hostDevice; 54 | this.#localDatabase = local; 55 | this.#remoteDatabase = remote; 56 | this.#deviceManager = deviceManager; 57 | } 58 | 59 | #getTrackLookupStrategy = (device: Device, type: TrackType) => { 60 | const isUnanalyzed = type === TrackType.AudioCD || type === TrackType.Unanalyzed; 61 | const requiresCdjRemote = 62 | device.type === DeviceType.CDJ && isUnanalyzed && this.cdjSupportsRemotedb; 63 | 64 | return device.type === DeviceType.Rekordbox || requiresCdjRemote 65 | ? LookupStrategy.Remote 66 | : device.type === DeviceType.CDJ && type === TrackType.RB 67 | ? LookupStrategy.Local 68 | : LookupStrategy.NoneAvailable; 69 | }; 70 | 71 | #getMediaLookupStrategy = (device: Device, slot: MediaSlot) => 72 | device.type === DeviceType.Rekordbox && slot === MediaSlot.RB 73 | ? LookupStrategy.Remote 74 | : device.type === DeviceType.Rekordbox 75 | ? LookupStrategy.NoneAvailable 76 | : LookupStrategy.Local; 77 | 78 | /** 79 | * Reports weather or not the CDJs can be communicated to over the remote 80 | * database protocol. This is important when trying to query for unanalyzed or 81 | * compact disc tracks. 82 | */ 83 | get cdjSupportsRemotedb() { 84 | return this.#hostDevice.id > 0 && this.#hostDevice.id < 7; 85 | } 86 | 87 | /** 88 | * Retrieve metadata for a track on a specific device slot. 89 | */ 90 | async getMetadata(opts: GetMetadata.Options) { 91 | const {deviceId, trackType, trackSlot, span} = opts; 92 | 93 | const tx = span 94 | ? span.startChild({op: 'dbGetMetadata'}) 95 | : Sentry.startTransaction({name: 'dbGetMetadata'}); 96 | 97 | tx.setTag('deviceId', deviceId.toString()); 98 | tx.setTag('trackType', getTrackTypeName(trackType)); 99 | tx.setTag('trackSlot', getSlotName(trackSlot)); 100 | 101 | const callOpts = {...opts, span: tx}; 102 | 103 | const device = await this.#deviceManager.getDeviceEnsured(deviceId); 104 | if (device === null) { 105 | return null; 106 | } 107 | 108 | const strategy = this.#getTrackLookupStrategy(device, trackType); 109 | let track: Track | null = null; 110 | 111 | if (strategy === LookupStrategy.Remote) { 112 | track = await GetMetadata.viaRemote(this.#remoteDatabase, callOpts); 113 | } 114 | 115 | if (strategy === LookupStrategy.Local) { 116 | track = await GetMetadata.viaLocal(this.#localDatabase, device, callOpts); 117 | } 118 | 119 | if (strategy === LookupStrategy.NoneAvailable) { 120 | tx.setStatus(SpanStatus.Unavailable); 121 | } 122 | 123 | tx.finish(); 124 | 125 | return track; 126 | } 127 | 128 | /** 129 | * Retrieves the artwork for a track on a specific device slot. 130 | */ 131 | async getArtwork(opts: GetArtwork.Options) { 132 | const {deviceId, trackType, trackSlot, span} = opts; 133 | 134 | const tx = span 135 | ? span.startChild({op: 'dbGetArtwork'}) 136 | : Sentry.startTransaction({name: 'dbGetArtwork'}); 137 | 138 | tx.setTag('deviceId', deviceId.toString()); 139 | tx.setTag('trackType', getTrackTypeName(trackType)); 140 | tx.setTag('trackSlot', getSlotName(trackSlot)); 141 | 142 | const callOpts = {...opts, span: tx}; 143 | 144 | const device = await this.#deviceManager.getDeviceEnsured(deviceId); 145 | if (device === null) { 146 | return null; 147 | } 148 | 149 | const strategy = this.#getTrackLookupStrategy(device, trackType); 150 | let artwork: Buffer | null = null; 151 | 152 | if (strategy === LookupStrategy.Remote) { 153 | artwork = await GetArtwork.viaRemote(this.#remoteDatabase, callOpts); 154 | } 155 | 156 | if (strategy === LookupStrategy.Local) { 157 | artwork = await GetArtwork.viaLocal(this.#localDatabase, device, callOpts); 158 | } 159 | 160 | if (strategy === LookupStrategy.NoneAvailable) { 161 | tx.setStatus(SpanStatus.Unavailable); 162 | } 163 | 164 | tx.finish(); 165 | 166 | return artwork; 167 | } 168 | 169 | /** 170 | * Retrieves the waveforms for a track on a specific device slot. 171 | */ 172 | async getWaveforms(opts: GetArtwork.Options) { 173 | const {deviceId, trackType, trackSlot, span} = opts; 174 | 175 | const tx = span 176 | ? span.startChild({op: 'dbGetWaveforms'}) 177 | : Sentry.startTransaction({name: 'dbGetWaveforms'}); 178 | 179 | tx.setTag('deviceId', deviceId.toString()); 180 | tx.setTag('trackType', getTrackTypeName(trackType)); 181 | tx.setTag('trackSlot', getSlotName(trackSlot)); 182 | 183 | const callOpts = {...opts, span: tx}; 184 | 185 | const device = await this.#deviceManager.getDeviceEnsured(deviceId); 186 | if (device === null) { 187 | return null; 188 | } 189 | 190 | const strategy = this.#getTrackLookupStrategy(device, trackType); 191 | let waveforms: Waveforms | null = null; 192 | 193 | if (strategy === LookupStrategy.Remote) { 194 | waveforms = await GetWaveforms.viaRemote(this.#remoteDatabase, callOpts); 195 | } 196 | 197 | if (strategy === LookupStrategy.Local) { 198 | waveforms = await GetWaveforms.viaLocal(this.#localDatabase, device, callOpts); 199 | } 200 | 201 | if (strategy === LookupStrategy.NoneAvailable) { 202 | tx.setStatus(SpanStatus.Unavailable); 203 | } 204 | 205 | tx.finish(); 206 | 207 | return waveforms; 208 | } 209 | 210 | /** 211 | * Retrieve folders, playlists, and tracks within the playlist tree. The id 212 | * may be left undefined to query the root of the playlist tree. 213 | * 214 | * NOTE: You will never receive a track list and playlists or folders at the 215 | * same time. But the API is simpler to combine the lookup for these. 216 | */ 217 | async getPlaylist(opts: GetPlaylist.Options) { 218 | const {deviceId, mediaSlot, span} = opts; 219 | 220 | const tx = span 221 | ? span.startChild({op: 'dbGetPlaylist'}) 222 | : Sentry.startTransaction({name: 'dbGetPlaylist'}); 223 | 224 | tx.setTag('deviceId', deviceId.toString()); 225 | tx.setTag('mediaSlot', getSlotName(mediaSlot)); 226 | 227 | const callOpts = {...opts, span: tx}; 228 | 229 | const device = await this.#deviceManager.getDeviceEnsured(deviceId); 230 | if (device === null) { 231 | return null; 232 | } 233 | 234 | const strategy = this.#getMediaLookupStrategy(device, mediaSlot); 235 | let contents: PlaylistContents | null = null; 236 | 237 | if (strategy === LookupStrategy.Remote) { 238 | contents = await GetPlaylist.viaRemote(this.#remoteDatabase, callOpts); 239 | } 240 | 241 | if (strategy === LookupStrategy.Local) { 242 | contents = await GetPlaylist.viaLocal(this.#localDatabase, callOpts); 243 | } 244 | 245 | if (strategy === LookupStrategy.NoneAvailable) { 246 | tx.setStatus(SpanStatus.Unavailable); 247 | } 248 | 249 | tx.finish(); 250 | 251 | return contents; 252 | } 253 | } 254 | 255 | export default Database; 256 | -------------------------------------------------------------------------------- /src/localdb/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {Mutex} from 'async-mutex'; 3 | import StrictEventEmitter from 'strict-event-emitter-types'; 4 | 5 | import {createHash} from 'crypto'; 6 | import {EventEmitter} from 'events'; 7 | 8 | import DeviceManager from 'src/devices'; 9 | import {fetchFile, FetchProgress} from 'src/nfs'; 10 | import StatusEmitter from 'src/status'; 11 | import { 12 | Device, 13 | DeviceID, 14 | DeviceType, 15 | MediaSlot, 16 | MediaSlotInfo, 17 | TrackType, 18 | } from 'src/types'; 19 | import {getSlotName} from 'src/utils'; 20 | 21 | import {MetadataORM} from './orm'; 22 | import {hydrateDatabase, HydrationProgress} from './rekordbox'; 23 | 24 | /** 25 | * Rekordbox databases will only exist within these two slots 26 | */ 27 | type DatabaseSlot = MediaSlot.USB | MediaSlot.SD; 28 | 29 | interface CommonProgressOpts { 30 | /** 31 | * The device progress is being reported for 32 | */ 33 | device: Device; 34 | /** 35 | * The media slot progress is being reported for 36 | */ 37 | slot: MediaSlot; 38 | } 39 | 40 | type DownloadProgressOpts = CommonProgressOpts & { 41 | /** 42 | * The current progress of the fetch 43 | */ 44 | progress: FetchProgress; 45 | }; 46 | 47 | type HydrationProgressOpts = CommonProgressOpts & { 48 | /** 49 | * The current progress of the database hydration 50 | */ 51 | progress: HydrationProgress; 52 | }; 53 | 54 | type HydrationDoneOpts = CommonProgressOpts; 55 | 56 | /** 57 | * Events that may be triggered by the LocalDatabase emitter 58 | */ 59 | interface DatabaseEvents { 60 | /** 61 | * Triggered when we are fetching a database from a CDJ 62 | */ 63 | fetchProgress: (opts: DownloadProgressOpts) => void; 64 | /** 65 | * Triggered when we are hydrating a rekordbox database into the in-memory 66 | * sqlite database. 67 | */ 68 | hydrationProgress: (opts: HydrationProgressOpts) => void; 69 | /** 70 | * Triggered when the database has been fully hydrated. 71 | * 72 | * There is a period of time between hydrationProgress reporting 100% copletion, 73 | * and the database being flushed, so it may be useful to wait for this event 74 | * before considering the database to be fully hydrated. 75 | */ 76 | hydrationDone: (opts: HydrationDoneOpts) => void; 77 | } 78 | 79 | type Emitter = StrictEventEmitter; 80 | 81 | interface DatabaseItem { 82 | /** 83 | * The uniquity identifier of the database 84 | */ 85 | id: string; 86 | /** 87 | * The media device plugged into the device 88 | */ 89 | media: MediaSlotInfo; 90 | /** 91 | * The MetadataORM service instance for the active connection 92 | */ 93 | orm: MetadataORM; 94 | } 95 | 96 | /** 97 | * Compute the identifier for media device in a CDJ. This is used to determine 98 | * if we have already hydrated the device or not into our local database. 99 | */ 100 | const getMediaId = (info: MediaSlotInfo) => { 101 | const inputs = [ 102 | info.deviceId, 103 | info.slot, 104 | info.name, 105 | info.freeBytes, 106 | info.totalBytes, 107 | info.trackCount, 108 | info.createdDate, 109 | ]; 110 | 111 | return createHash('sha256').update(inputs.join('.'), 'utf8').digest().toString(); 112 | }; 113 | 114 | /** 115 | * The local database is responsible for syncing the remote rekordbox databases 116 | * of media slots on a device into in-memory sqlite databases. 117 | * 118 | * This service will attempt to ensure the in-memory databases for each media 119 | * device that is connected to a CDJ is locally kept in sync. Fetching the 120 | * database for any media slot of it's not already cached. 121 | */ 122 | class LocalDatabase { 123 | #hostDevice: Device; 124 | #deviceManager: DeviceManager; 125 | #statusEmitter: StatusEmitter; 126 | /** 127 | * The EventEmitter that will report database events 128 | */ 129 | #emitter: Emitter = new EventEmitter(); 130 | /** 131 | * Locks for each device slot: ${device.id}-${slot}. Used when making track 132 | * requets. 133 | */ 134 | #slotLocks = new Map(); 135 | /** 136 | * The current available databases 137 | */ 138 | #dbs: DatabaseItem[] = []; 139 | 140 | constructor( 141 | hostDevice: Device, 142 | deviceManager: DeviceManager, 143 | statusEmitter: StatusEmitter 144 | ) { 145 | this.#hostDevice = hostDevice; 146 | this.#deviceManager = deviceManager; 147 | this.#statusEmitter = statusEmitter; 148 | 149 | deviceManager.on('disconnected', this.#handleDeviceRemoved); 150 | } 151 | 152 | // Bind public event emitter interface 153 | on: Emitter['on'] = this.#emitter.addListener.bind(this.#emitter); 154 | off: Emitter['off'] = this.#emitter.removeListener.bind(this.#emitter); 155 | once: Emitter['once'] = this.#emitter.once.bind(this.#emitter); 156 | 157 | /** 158 | * Disconnects the local database connection for the specified device 159 | */ 160 | disconnectForDevice(device: Device) { 161 | this.#handleDeviceRemoved(device); 162 | } 163 | 164 | /** 165 | * Closes the database connection and removes the database entry when a 166 | * device is removed. 167 | */ 168 | #handleDeviceRemoved = (device: Device) => { 169 | this.#dbs.find(db => db.media.deviceId === device.id)?.orm.close(); 170 | this.#dbs = this.#dbs.filter(db => db.media.deviceId !== device.id); 171 | }; 172 | 173 | /** 174 | * Downloads and hydrates a new in-memory sqlite database 175 | */ 176 | #hydrateDatabase = async (device: Device, slot: DatabaseSlot, media: MediaSlotInfo) => { 177 | const tx = Sentry.startTransaction({name: 'hydrateDatabase'}); 178 | 179 | tx.setTag('slot', getSlotName(media.slot)); 180 | tx.setData('numTracks', media.trackCount.toString()); 181 | 182 | const dbCreateTx = tx.startChild({op: 'setupDatabase'}); 183 | const orm = new MetadataORM(); 184 | dbCreateTx.finish(); 185 | 186 | let pdbData = Buffer.alloc(0); 187 | 188 | const fetchPdbData = async (path: string) => 189 | (pdbData = await fetchFile({ 190 | device, 191 | slot, 192 | path, 193 | span: tx, 194 | onProgress: progress => 195 | this.#emitter.emit('fetchProgress', {device, slot, progress}), 196 | })); 197 | 198 | // Rekordbox exports to both the `.PIONEER` and `PIONEER` folder, depending 199 | // on the media devices filesystem (HFS, FAT32, etc). Unfortunately there's no 200 | // way for us to know the type of filesystem, so we have to try both 201 | const path = 'PIONEER/rekordbox/export.pdb'; 202 | 203 | // Attempt to be semi-smart and first try the path coorelating to the OS 204 | // they're running this on. The assumption is they may have used the same 205 | // machine to export their tracks on. 206 | const attemptOrder = 207 | process.platform === 'win32' ? [path, `.${path}`] : [`.${path}`, path]; 208 | 209 | try { 210 | await fetchPdbData(attemptOrder[0]); 211 | } catch { 212 | await fetchPdbData(attemptOrder[1]); 213 | } 214 | 215 | await hydrateDatabase({ 216 | orm, 217 | pdbData, 218 | span: tx, 219 | onProgress: progress => 220 | this.#emitter.emit('hydrationProgress', {device, slot, progress}), 221 | }); 222 | this.#emitter.emit('hydrationDone', {device, slot}); 223 | 224 | const db = {orm, media, id: getMediaId(media)}; 225 | this.#dbs.push(db); 226 | 227 | tx.finish(); 228 | 229 | return db; 230 | }; 231 | 232 | /** 233 | * Gets the sqlite ORM service for to a database hydrated with the media 234 | * metadata for the provided device slot. 235 | * 236 | * If the database has not already been hydrated this will first hydrate the 237 | * database, which may take some time depending on the size of the database. 238 | * 239 | * @returns null if no rekordbox media present 240 | */ 241 | async get(deviceId: DeviceID, slot: DatabaseSlot) { 242 | const lockKey = `${deviceId}-${slot}`; 243 | const lock = 244 | this.#slotLocks.get(lockKey) ?? 245 | this.#slotLocks.set(lockKey, new Mutex()).get(lockKey)!; 246 | 247 | const device = this.#deviceManager.devices.get(deviceId); 248 | if (device === undefined) { 249 | return null; 250 | } 251 | 252 | if (device.type !== DeviceType.CDJ) { 253 | throw new Error('Cannot create database from devices that are not CDJs'); 254 | } 255 | 256 | const media = await this.#statusEmitter.queryMediaSlot({ 257 | hostDevice: this.#hostDevice, 258 | device, 259 | slot, 260 | }); 261 | 262 | if (media.tracksType !== TrackType.RB) { 263 | return null; 264 | } 265 | 266 | const id = getMediaId(media); 267 | 268 | // Acquire a lock for this device slot that will not release until we've 269 | // guaranteed the existence of the database. 270 | const db = await lock.runExclusive( 271 | () => 272 | this.#dbs.find(db => db.id === id) ?? this.#hydrateDatabase(device, slot, media) 273 | ); 274 | 275 | return db.orm; 276 | } 277 | 278 | /** 279 | * Preload the databases for all connected devices. 280 | */ 281 | async preload() { 282 | const loaders = [...this.#deviceManager.devices.values()] 283 | .filter(device => device.type === DeviceType.CDJ) 284 | .map(device => 285 | Promise.all([ 286 | this.get(device.id, MediaSlot.USB), 287 | this.get(device.id, MediaSlot.SD), 288 | ]) 289 | ); 290 | 291 | await Promise.all(loaders); 292 | } 293 | } 294 | 295 | export default LocalDatabase; 296 | -------------------------------------------------------------------------------- /src/remotedb/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {Span} from '@sentry/tracing'; 3 | import {Mutex} from 'async-mutex'; 4 | import * as ip from 'ip-address'; 5 | import PromiseSocket from 'promise-socket'; 6 | 7 | import {Socket} from 'net'; 8 | 9 | import DeviceManager from 'src/devices'; 10 | import {Device, DeviceID, MediaSlot, TrackType} from 'src/types'; 11 | 12 | import {getMessageName, MessageType, Request, Response} from './message/types'; 13 | import {REMOTEDB_SERVER_QUERY_PORT} from './constants'; 14 | import {readField, UInt32} from './fields'; 15 | import {Message} from './message'; 16 | import {HandlerArgs, HandlerReturn, queryHandlers} from './queries'; 17 | 18 | type Await = T extends PromiseLike ? U : T; 19 | 20 | /** 21 | * Menu target specifies where a menu should be "rendered" This differs based 22 | * on the request being made. 23 | */ 24 | export enum MenuTarget { 25 | Main = 0x01, 26 | } 27 | 28 | /** 29 | * Used to specify where to lookup data when making queries 30 | */ 31 | export interface QueryDescriptor { 32 | menuTarget: MenuTarget; 33 | trackSlot: MediaSlot; 34 | trackType: TrackType; 35 | } 36 | 37 | /** 38 | * Used internally when making queries. 39 | */ 40 | export type LookupDescriptor = QueryDescriptor & { 41 | targetDevice: Device; 42 | hostDevice: Device; 43 | }; 44 | 45 | /** 46 | * Used to specify the query type that is being made 47 | */ 48 | export type Query = keyof typeof queryHandlers; 49 | export const Query = Request; 50 | 51 | const QueryInverse = Object.fromEntries(Object.entries(Query).map(e => [e[1], e[0]])); 52 | 53 | /** 54 | * Returns a string representation of a remote query 55 | */ 56 | export function getQueryName(query: Query) { 57 | return QueryInverse[query]; 58 | } 59 | 60 | /** 61 | * Options used to make a remotedb query 62 | */ 63 | interface QueryOpts { 64 | queryDescriptor: QueryDescriptor; 65 | /** 66 | * The query type to make 67 | */ 68 | query: T; 69 | /** 70 | * Arguments to pass to the query. These are query specific 71 | */ 72 | args: HandlerArgs; 73 | /** 74 | * The sentry span to associate the query with 75 | */ 76 | span?: Span; 77 | } 78 | 79 | /** 80 | * Queries the remote device for the port that the remote database server is 81 | * listening on for requests. 82 | */ 83 | async function getRemoteDBServerPort(deviceIp: ip.Address4) { 84 | const conn = new PromiseSocket(new Socket()); 85 | await conn.connect(REMOTEDB_SERVER_QUERY_PORT, deviceIp.address); 86 | 87 | // Magic request packet asking the device to report it's remoteDB port 88 | const data = Buffer.from([ 89 | ...[0x00, 0x00, 0x00, 0x0f], 90 | ...Buffer.from('RemoteDBServer', 'ascii'), 91 | 0x00, 92 | ]); 93 | 94 | await conn.write(data); 95 | const resp = await conn.read(); 96 | 97 | if (typeof resp !== 'object') { 98 | throw new Error('Invalid response from remotedb'); 99 | } 100 | 101 | if (resp.length !== 2) { 102 | throw new Error(`Expected 2 bytes, got ${resp.length}`); 103 | } 104 | 105 | return resp.readUInt16BE(); 106 | } 107 | 108 | /** 109 | * Manages a connection to a single device 110 | */ 111 | export class Connection { 112 | #socket: PromiseSocket; 113 | #txId = 0; 114 | #lock = new Mutex(); 115 | 116 | device: Device; 117 | 118 | constructor(device: Device, socket: PromiseSocket) { 119 | this.#socket = socket; 120 | this.device = device; 121 | } 122 | 123 | async writeMessage(message: Message, span: Span) { 124 | const tx = span.startChild({ 125 | op: 'writeMessage', 126 | description: getMessageName(message.type), 127 | }); 128 | 129 | message.transactionId = ++this.#txId; 130 | await this.#socket.write(message.buffer); 131 | tx.finish(); 132 | } 133 | 134 | readMessage(expect: T, span: Span) { 135 | return this.#lock.runExclusive(() => Message.fromStream(this.#socket, expect, span)); 136 | } 137 | 138 | close() { 139 | this.#socket.destroy(); 140 | } 141 | } 142 | 143 | export class QueryInterface { 144 | #conn: Connection; 145 | #hostDevice: Device; 146 | #lock: Mutex; 147 | 148 | constructor(conn: Connection, lock: Mutex, hostDevice: Device) { 149 | this.#conn = conn; 150 | this.#lock = lock; 151 | this.#hostDevice = hostDevice; 152 | } 153 | 154 | /** 155 | * Make a query to the remote database connection. 156 | */ 157 | async query(opts: QueryOpts): Promise>> { 158 | const {query, queryDescriptor, args, span} = opts; 159 | const conn = this.#conn; 160 | 161 | const queryName = getQueryName(opts.query); 162 | 163 | const tx = span 164 | ? span.startChild({op: 'remoteQuery', description: queryName}) 165 | : Sentry.startTransaction({name: 'remoteQuery', description: queryName}); 166 | 167 | const lookupDescriptor: LookupDescriptor = { 168 | ...queryDescriptor, 169 | hostDevice: this.#hostDevice, 170 | targetDevice: this.#conn.device, 171 | }; 172 | 173 | // TODO: Figure out why typescirpt can't understand our query type discriminate 174 | // for args here. The interface for this actual query function discrimites just 175 | // fine. 176 | const anyArgs = args as any; 177 | 178 | const handler = queryHandlers[query]; 179 | 180 | const releaseLock = await this.#lock.acquire(); 181 | const response = await handler({conn, lookupDescriptor, span: tx, args: anyArgs}); 182 | releaseLock(); 183 | tx.finish(); 184 | 185 | return response as Await>; 186 | } 187 | } 188 | 189 | /** 190 | * Service that maintains remote database connections with devices on the network. 191 | */ 192 | export default class RemoteDatabase { 193 | #hostDevice: Device; 194 | #deviceManager: DeviceManager; 195 | 196 | /** 197 | * Active device connection map 198 | */ 199 | #connections = new Map(); 200 | /** 201 | * Locks for each device when locating the connection 202 | */ 203 | #deviceLocks = new Map(); 204 | 205 | constructor(deviceManager: DeviceManager, hostDevice: Device) { 206 | this.#deviceManager = deviceManager; 207 | this.#hostDevice = hostDevice; 208 | } 209 | 210 | /** 211 | * Open a connection to the specified device for querying 212 | */ 213 | connectToDevice = async (device: Device) => { 214 | const tx = Sentry.startTransaction({name: 'connectRemotedb', data: {device}}); 215 | 216 | const {ip} = device; 217 | 218 | const dbPort = await getRemoteDBServerPort(ip); 219 | 220 | const socket = new PromiseSocket(new Socket()); 221 | await socket.connect(dbPort, ip.address); 222 | 223 | // Send required preamble to open communications with the device 224 | const preamble = new UInt32(0x01); 225 | await socket.write(preamble.buffer); 226 | 227 | // Read the response. It should be a UInt32 field with the value 0x01. 228 | // There is some kind of problem if not. 229 | const data = await readField(socket, UInt32.type); 230 | 231 | if (data.value !== 0x01) { 232 | throw new Error(`Expected 0x01 during preamble handshake. Got ${data.value}`); 233 | } 234 | 235 | // Send introduction message to set context for querying 236 | const intro = new Message({ 237 | transactionId: 0xfffffffe, 238 | type: MessageType.Introduce, 239 | args: [new UInt32(this.#hostDevice.id)], 240 | }); 241 | 242 | await socket.write(intro.buffer); 243 | const resp = await Message.fromStream(socket, MessageType.Success, tx); 244 | 245 | if (resp.type !== MessageType.Success) { 246 | throw new Error(`Failed to introduce self to device ID: ${device.id}`); 247 | } 248 | 249 | this.#connections.set(device.id, new Connection(device, socket)); 250 | tx.finish(); 251 | }; 252 | 253 | /** 254 | * Disconnect from the specified device 255 | */ 256 | disconnectFromDevice = async (device: Device) => { 257 | const tx = Sentry.startTransaction({name: 'disconnectFromDevice', data: {device}}); 258 | 259 | const conn = this.#connections.get(device.id); 260 | 261 | if (conn === undefined) { 262 | return; 263 | } 264 | 265 | const goodbye = new Message({ 266 | transactionId: 0xfffffffe, 267 | type: MessageType.Disconnect, 268 | args: [], 269 | }); 270 | 271 | await conn.writeMessage(goodbye, tx); 272 | 273 | conn.close(); 274 | this.#connections.delete(device.id); 275 | tx.finish(); 276 | }; 277 | 278 | /** 279 | * Gets the remote database query interface for the given device. 280 | * 281 | * If we have not already established a connection with the specified device, 282 | * we will attempt to first connect. 283 | * 284 | * @returns null if the device does not export a remote database service 285 | */ 286 | async get(deviceId: DeviceID) { 287 | const device = this.#deviceManager.devices.get(deviceId); 288 | if (device === undefined) { 289 | return null; 290 | } 291 | 292 | const lock = 293 | this.#deviceLocks.get(device.id) ?? 294 | this.#deviceLocks.set(device.id, new Mutex()).get(device.id)!; 295 | 296 | const releaseLock = await lock.acquire(); 297 | 298 | let conn = this.#connections.get(deviceId); 299 | if (conn === undefined) { 300 | await this.connectToDevice(device); 301 | } 302 | 303 | conn = this.#connections.get(deviceId)!; 304 | releaseLock(); 305 | 306 | // NOTE: We pass the same lock we use for this device to the query 307 | // interface to ensure all query interfaces use the same lock. 308 | 309 | return new QueryInterface(conn, lock, this.#hostDevice); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type {Address4} from 'ip-address'; 2 | 3 | import type {Playlist, Track} from './entities'; 4 | 5 | export * as CDJStatus from 'src/status/types'; 6 | 7 | /** 8 | * Re-export various types for the types only compile target 9 | */ 10 | 11 | export type { 12 | Album, 13 | Artist, 14 | Artwork, 15 | Color, 16 | Genre, 17 | Key, 18 | Label, 19 | Playlist, 20 | Track, 21 | } from './entities'; 22 | export type {HydrationProgress} from './localdb/rekordbox'; 23 | export type {MixstatusConfig, MixstatusProcessor} from './mixstatus'; 24 | export type {ConnectedProlinkNetwork, NetworkConfig, ProlinkNetwork} from './network'; 25 | export type {FetchProgress} from './nfs'; 26 | 27 | /** 28 | * Known device types on the network 29 | */ 30 | export enum DeviceType { 31 | CDJ = 0x01, 32 | Mixer = 0x03, 33 | Rekordbox = 0x04, 34 | } 35 | 36 | /** 37 | * The 8-bit identifier of the device on the network 38 | */ 39 | export type DeviceID = number; 40 | 41 | /** 42 | * Represents a device on the prolink network. 43 | */ 44 | export interface Device { 45 | name: string; 46 | id: DeviceID; 47 | type: DeviceType; 48 | macAddr: Uint8Array; 49 | ip: Address4; 50 | lastActive?: Date; 51 | } 52 | 53 | /** 54 | * Details of a particular media slot on the CDJ 55 | */ 56 | export interface MediaSlotInfo { 57 | /** 58 | * The device the slot physically exists on 59 | */ 60 | deviceId: DeviceID; 61 | /** 62 | * The slot type 63 | */ 64 | slot: MediaSlot; 65 | /** 66 | * The name of the media connected 67 | */ 68 | name: string; 69 | /** 70 | * The rekordbox configured color of the media connected 71 | */ 72 | color: MediaColor; 73 | /** 74 | * Creation date 75 | */ 76 | createdDate: Date; 77 | /** 78 | * Number of free bytes available on the media 79 | */ 80 | freeBytes: bigint; 81 | /** 82 | * Number of bytes used on the media 83 | */ 84 | totalBytes: bigint; 85 | /** 86 | * Specifies the available tracks type on the media 87 | */ 88 | tracksType: TrackType; 89 | /** 90 | * Total number of rekordbox tracks on the media. Will be zero if there is 91 | * no rekordbox database on the media 92 | */ 93 | trackCount: number; 94 | /** 95 | * Same as track count, except for playlists 96 | */ 97 | playlistCount: number; 98 | /** 99 | * True when a rekordbox 'my settings' file has been exported to the media 100 | */ 101 | hasSettings: boolean; 102 | } 103 | 104 | export enum MediaColor { 105 | Default = 0x00, 106 | Pink = 0x01, 107 | Red = 0x02, 108 | Orange = 0x03, 109 | Yellow = 0x04, 110 | Green = 0x05, 111 | Aqua = 0x06, 112 | Blue = 0x07, 113 | Purple = 0x08, 114 | } 115 | 116 | /** 117 | * A slot where media is present on the CDJ 118 | */ 119 | export enum MediaSlot { 120 | Empty = 0x00, 121 | CD = 0x01, 122 | SD = 0x02, 123 | USB = 0x03, 124 | RB = 0x04, 125 | } 126 | 127 | /** 128 | * Track type flags 129 | */ 130 | export enum TrackType { 131 | None = 0x00, 132 | RB = 0x01, 133 | Unanalyzed = 0x02, 134 | AudioCD = 0x05, 135 | } 136 | 137 | /** 138 | * A beat grid is a series of offsets from the start of the track. Each offset 139 | * indicates what count within the measure it is along with the BPM. 140 | */ 141 | export type BeatGrid = Array<{ 142 | /** 143 | * Offset from the beginning of track in milliseconds of this beat. 144 | */ 145 | offset: number; 146 | /** 147 | * The count of this particular beat within the measure 148 | */ 149 | count: 1 | 2 | 3 | 4; 150 | /** 151 | * The BPM at this beat. 152 | */ 153 | bpm: number; 154 | }>; 155 | 156 | /** 157 | * A waveform segment contains a height and 'whiteness' value. 158 | */ 159 | interface WaveformSegment { 160 | /** 161 | * The height this segment in the waveform. Ranges from 0 - 31. 162 | */ 163 | height: number; 164 | /** 165 | * The level of "whiteness" of the waveform. 0 being completely blue, and 1 166 | * being completely white. 167 | */ 168 | whiteness: number; 169 | } 170 | 171 | /** 172 | * A HD waveform segment contains the height of the waveform, and it's color 173 | * represented as RGB values. 174 | */ 175 | interface WaveformHDSegment { 176 | /** 177 | * The height this segment in the waveform. Ranges from 0 - 31. 178 | */ 179 | height: number; 180 | /** 181 | * the RGB value, each channel ranges from 0-1 for the segment. 182 | */ 183 | color: [number, number, number]; 184 | } 185 | 186 | /** 187 | * The waveform preview will be 400 segments of data. 188 | */ 189 | export type WaveformPreview = WaveformSegment[]; 190 | 191 | /** 192 | * Detailed waveforms have 150 segments per second of audio (150 'half frames' 193 | * per second of audio). 194 | */ 195 | export type WaveformDetailed = WaveformSegment[]; 196 | 197 | /** 198 | * HD waveforms have 150 segments per second of audio (150 'half frames' per 199 | * second of audio). 200 | */ 201 | export type WaveformHD = WaveformHDSegment[]; 202 | 203 | /** 204 | * The result of looking up track waveforms 205 | */ 206 | export interface Waveforms { 207 | /** 208 | * The full-size and full-color waveform 209 | */ 210 | waveformHd: WaveformHD; 211 | 212 | // TODO: Add other waveform types 213 | } 214 | 215 | /** 216 | * A hotcue button label 217 | */ 218 | export enum HotcueButton { 219 | A = 1, 220 | B, 221 | C, 222 | D, 223 | E, 224 | F, 225 | G, 226 | H, 227 | } 228 | 229 | /** 230 | * When a custom color is not configured the cue point will be one of these 231 | * colors. 232 | */ 233 | export enum CueColor { 234 | None = 0x00, 235 | Blank = 0x15, 236 | Magenta = 0x31, 237 | Violet = 0x38, 238 | Fuchsia = 0x3c, 239 | LightSlateBlue = 0x3e, 240 | Blue = 0x01, 241 | SteelBlue = 0x05, 242 | Aqua = 0x09, 243 | SeaGreen = 0x0e, 244 | Teal = 0x12, 245 | Green = 0x16, 246 | Lime = 0x1a, 247 | Olive = 0x1e, 248 | Yellow = 0x20, 249 | Orange = 0x26, 250 | Red = 0x2a, 251 | Pink = 0x2d, 252 | } 253 | 254 | /** 255 | * Represents a single cue point. On older exports the label and color may be 256 | * undefined. 257 | */ 258 | export interface CuePoint { 259 | type: 'cue_point'; 260 | /** 261 | * Number of milliseconds from the start of the track. 262 | */ 263 | offset: number; 264 | /** 265 | * The comment associated to the cue point 266 | */ 267 | label?: string; 268 | /** 269 | * RGB values of the hotcue color 270 | */ 271 | color?: CueColor; 272 | } 273 | 274 | type BareCuePoint = Omit; 275 | 276 | /** 277 | * A loop, similar to a cue point, but includes a length. 278 | */ 279 | export type Loop = BareCuePoint & { 280 | type: 'loop'; 281 | /** 282 | * The length in milliseconds of the loop 283 | */ 284 | length: number; 285 | }; 286 | 287 | /** 288 | * A hotcue is like a cue point, but also includes the button it is assigned to. 289 | */ 290 | export type Hotcue = BareCuePoint & { 291 | type: 'hot_cue'; 292 | /** 293 | * Which hotcue button this hotcue is assigned to. 294 | */ 295 | button: HotcueButton; 296 | }; 297 | 298 | /** 299 | * A hot loop, this is the union of a hotcue and a loop. 300 | */ 301 | export type Hotloop = {type: 'hot_loop'} & (Omit & Omit); 302 | 303 | export type CueAndLoop = CuePoint | Loop | Hotcue | Hotloop; 304 | 305 | /** 306 | * Represents the contents of a playlist 307 | */ 308 | export interface PlaylistContents { 309 | /** 310 | * The playlists in this playlist. 311 | */ 312 | playlists: Playlist[]; 313 | /** 314 | * The folders in this playlist. 315 | */ 316 | folders: Playlist[]; 317 | /** 318 | * The tracks in this playlist. This is an AsyncIterator as looking up track 319 | * metadata may be slow when connected to the remote database. 320 | */ 321 | tracks: AsyncIterable; 322 | /** 323 | * The total number of tracks in this playlist. 324 | */ 325 | totalTracks: number; 326 | } 327 | 328 | export enum NetworkState { 329 | /** 330 | * The network is offline when we don't have an open connection to the network 331 | * (no connection to the announcement and or status UDP socket is present). 332 | */ 333 | Offline, 334 | /** 335 | * The network is online when we have opened sockets to the network, but have 336 | * not yet started announcing ourselves as a virtual CDJ. 337 | */ 338 | Online, 339 | /** 340 | * The network is connected once we have heard from another device on the network 341 | */ 342 | Connected, 343 | /** 344 | * The network may have failed to connect if we aren't able to open the 345 | * announcement and or status UDP socket. 346 | */ 347 | Failed, 348 | } 349 | 350 | /** 351 | * Mixstatus reporting modes specify how the mixstatus processor will determine when a new 352 | * track is 'now playing'. 353 | */ 354 | export enum MixstatusMode { 355 | /** 356 | * Tracks will be smartly marked as playing following rules: 357 | * 358 | * - The track that has been in the play state with the CDJ in the "on air" state 359 | * for the longest period of time (allowing for a configurable length of 360 | * interruption with allowedInterruptBeats) is considered to be the active 361 | * track that incoming tracks will be compared against. 362 | * 363 | * - A incoming track will immediately be reported as nowPlaying if it is on 364 | * air, playing, and the last active track has been cued. 365 | * 366 | * - A incoming track will be reported as nowPlaying if the active track has 367 | * not been on air or has not been playing for the configured 368 | * allowedInterruptBeats. 369 | * 370 | * - A incoming track will be reported as nowPlaying if it has played 371 | * consecutively (with allowedInterruptBeats honored for the incoming track) 372 | * for the configured beatsUntilReported. 373 | */ 374 | SmartTiming, 375 | /** 376 | * Tracks will not be reported after the beatsUntilReported AND will ONLY 377 | * be reported if the other track has gone into a non-playing play state, or 378 | * taken off air (when useOnAirStatus is enabled). 379 | */ 380 | WaitsForSilence, 381 | /** 382 | * The track will simply be reported only after the player becomes master. 383 | */ 384 | FollowsMaster, 385 | } 386 | -------------------------------------------------------------------------------- /src/network.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import {SpanStatus} from '@sentry/tracing'; 3 | 4 | import {randomUUID} from 'crypto'; 5 | import dgram, {Socket} from 'dgram'; 6 | import {NetworkInterfaceInfoIPv4} from 'os'; 7 | 8 | import {ANNOUNCE_PORT, BEAT_PORT, DEFAULT_VCDJ_ID, STATUS_PORT} from 'src/constants'; 9 | import Control from 'src/control'; 10 | import Database from 'src/db'; 11 | import DeviceManager from 'src/devices'; 12 | import LocalDatabase from 'src/localdb'; 13 | import {MixstatusProcessor} from 'src/mixstatus'; 14 | import RemoteDatabase from 'src/remotedb'; 15 | import StatusEmitter from 'src/status'; 16 | import {Device, NetworkState} from 'src/types'; 17 | import {getMatchingInterface} from 'src/utils'; 18 | import {udpBind, udpClose} from 'src/utils/udp'; 19 | import {Announcer, getVirtualCDJ} from 'src/virtualcdj'; 20 | 21 | const connectErrorHelp = 22 | 'Network must be configured. Try using `autoconfigFromPeers` or `configure`'; 23 | 24 | export interface NetworkConfig { 25 | /** 26 | * The network interface to listen for devices on the network over 27 | */ 28 | iface: NetworkInterfaceInfoIPv4; 29 | /** 30 | * The ID of the virtual CDJ to pose as. 31 | * 32 | * IMPORTANT: 33 | * 34 | * You will likely want to configure this to be > 6, however it is important to 35 | * note, if you choose an ID within the 1-6 range, no other CDJ may exist on the 36 | * network using that ID. you CAN NOT have 6 CDJs if you're using one of their slots. 37 | * 38 | * However, should you want to make metadata queries to a unanalized media 39 | * device connected to the CDJ, or metadata queries for CD disc data, you MUST 40 | * use a ID within the 1-6 range, as the CDJs will not respond to metadata 41 | * requests outside of the range of 1-6 42 | * 43 | * Note that rekordbox analyzed media connected to the CDJ is accessed out of 44 | * band of the networks remote database protocol, and is not limited by this 45 | * restriction. 46 | */ 47 | vcdjId: number; 48 | } 49 | 50 | interface ConnectionService { 51 | announcer: Announcer; 52 | control: Control; 53 | remotedb: RemoteDatabase; 54 | localdb: LocalDatabase; 55 | database: Database; 56 | } 57 | 58 | interface ConstructOpts { 59 | config?: NetworkConfig; 60 | announceSocket: Socket; 61 | beatSocket: Socket; 62 | statusSocket: Socket; 63 | deviceManager: DeviceManager; 64 | statusEmitter: StatusEmitter; 65 | } 66 | 67 | /** 68 | * Services that are not accessible until connected 69 | */ 70 | type ConnectedServices = 71 | | 'statusEmitter' 72 | | 'control' 73 | | 'db' 74 | | 'localdb' 75 | | 'remotedb' 76 | | 'mixstatus'; 77 | 78 | export type ConnectedProlinkNetwork = ProlinkNetwork & { 79 | [P in ConnectedServices]: NonNullable; 80 | } & { 81 | state: NetworkState.Connected; 82 | isConfigured: true; 83 | }; 84 | 85 | /** 86 | * Brings the Prolink network online. 87 | * 88 | * This is the primary entrypoint for connecting to the prolink network. 89 | */ 90 | export async function bringOnline(config?: NetworkConfig) { 91 | Sentry.setTag('connectionId', randomUUID()); 92 | const tx = Sentry.startTransaction({name: 'bringOnline'}); 93 | 94 | // Socket used to listen for devices on the network 95 | const announceSocket = dgram.createSocket('udp4'); 96 | 97 | // Socket used to listen for beat timing information 98 | const beatSocket = dgram.createSocket('udp4'); 99 | 100 | // Socket used to listen for status packets 101 | const statusSocket = dgram.createSocket('udp4'); 102 | 103 | try { 104 | await udpBind(announceSocket, ANNOUNCE_PORT, '0.0.0.0'); 105 | await udpBind(beatSocket, BEAT_PORT, '0.0.0.0'); 106 | await udpBind(statusSocket, STATUS_PORT, '0.0.0.0'); 107 | } catch (err) { 108 | Sentry.captureException(err); 109 | tx.setStatus(SpanStatus.Unavailable); 110 | tx.finish(); 111 | 112 | throw err; 113 | } 114 | 115 | const deviceManager = new DeviceManager(announceSocket); 116 | const statusEmitter = new StatusEmitter(statusSocket); 117 | 118 | tx.finish(); 119 | 120 | const network = new ProlinkNetwork({ 121 | config, 122 | announceSocket, 123 | beatSocket, 124 | statusSocket, 125 | deviceManager, 126 | statusEmitter, 127 | }); 128 | 129 | return network; 130 | } 131 | 132 | export class ProlinkNetwork { 133 | #state: NetworkState = NetworkState.Online; 134 | 135 | #announceSocket: Socket; 136 | #beatSocket: Socket; 137 | #statusSocket: Socket; 138 | #deviceManager: DeviceManager; 139 | #statusEmitter: StatusEmitter; 140 | 141 | #config: null | NetworkConfig; 142 | #connection: null | ConnectionService; 143 | #mixstatus: null | MixstatusProcessor; 144 | 145 | /** 146 | * @internal 147 | */ 148 | constructor({ 149 | config, 150 | announceSocket, 151 | beatSocket, 152 | statusSocket, 153 | deviceManager, 154 | statusEmitter, 155 | }: ConstructOpts) { 156 | this.#config = config ?? null; 157 | 158 | this.#announceSocket = announceSocket; 159 | this.#beatSocket = beatSocket; 160 | this.#statusSocket = statusSocket; 161 | this.#deviceManager = deviceManager; 162 | this.#statusEmitter = statusEmitter; 163 | 164 | this.#connection = null; 165 | this.#mixstatus = null; 166 | 167 | // We always start online when constructing the network 168 | this.#state = NetworkState.Online; 169 | } 170 | 171 | /** 172 | * Configure / reconfigure the network with an explicit configuration. 173 | * 174 | * You may need to disconnect and re-connect the network after making a 175 | * networking configuration change. 176 | */ 177 | configure(config: NetworkConfig) { 178 | this.#config = {...this.#config, ...config}; 179 | } 180 | 181 | /** 182 | * Wait for another device to show up on the network to determine which network 183 | * interface to listen on. 184 | * 185 | * Defaults the Virtual CDJ ID to 7. 186 | */ 187 | async autoconfigFromPeers() { 188 | const tx = Sentry.startTransaction({name: 'autoConfigure'}); 189 | // wait for first device to appear on the network 190 | const firstDevice = await new Promise(resolve => 191 | this.#deviceManager.once('connected', resolve) 192 | ); 193 | const iface = getMatchingInterface(firstDevice.ip); 194 | 195 | // Log addr and iface addr / mask for cases where it may have matched the 196 | // wrong interface 197 | tx.setTag('deviceName', firstDevice.name); 198 | tx.setData('deviceAddr', firstDevice.ip.address); 199 | tx.setData('ifaceAddr', iface?.address); 200 | 201 | if (iface === null) { 202 | tx.setStatus(SpanStatus.InternalError); 203 | tx.setTag('noIfaceFound', 'yes'); 204 | tx.finish(); 205 | 206 | throw new Error('Unable to determine network interface'); 207 | } 208 | 209 | this.#config = {...this.#config, vcdjId: DEFAULT_VCDJ_ID, iface}; 210 | tx.finish(); 211 | } 212 | 213 | /** 214 | * Connect to the network. 215 | * 216 | * The network must first have been configured (either with autoconfigFromPeers 217 | * or manual configuration). This will then initialize all the network services. 218 | */ 219 | connect() { 220 | if (this.#config === null) { 221 | throw new Error(connectErrorHelp); 222 | } 223 | 224 | const tx = Sentry.startTransaction({name: 'connect'}); 225 | 226 | // Create VCDJ for the interface's broadcast address 227 | const vcdj = getVirtualCDJ(this.#config.iface, this.#config.vcdjId); 228 | 229 | // Start announcing 230 | const announcer = new Announcer(vcdj, this.#announceSocket, this.deviceManager); 231 | announcer.start(); 232 | 233 | // Create remote and local databases 234 | const remotedb = new RemoteDatabase(this.#deviceManager, vcdj); 235 | const localdb = new LocalDatabase(vcdj, this.#deviceManager, this.#statusEmitter); 236 | 237 | // Create unified database 238 | const database = new Database(vcdj, localdb, remotedb, this.#deviceManager); 239 | 240 | // Create controller service 241 | const control = new Control(this.#beatSocket, vcdj); 242 | 243 | this.#state = NetworkState.Connected; 244 | this.#connection = {announcer, control, remotedb, localdb, database}; 245 | 246 | tx.finish(); 247 | } 248 | 249 | /** 250 | * Disconnect from the network 251 | */ 252 | disconnect() { 253 | if (this.#config === null) { 254 | throw new Error(connectErrorHelp); 255 | } 256 | 257 | // Stop announcing ourself 258 | this.#connection?.announcer.stop(); 259 | 260 | // Disconnect devices from the remote and local databases 261 | for (const device of this.deviceManager.devices.values()) { 262 | this.remotedb?.disconnectFromDevice(device); 263 | this.localdb?.disconnectForDevice(device); 264 | } 265 | 266 | return Promise.all([ 267 | udpClose(this.#announceSocket), 268 | udpClose(this.#statusSocket), 269 | udpClose(this.#beatSocket), 270 | ]); 271 | } 272 | 273 | /** 274 | * Get the current NetworkState of the network. 275 | * 276 | * When the network is Online you may use the deviceManager to list and react to 277 | * devices on the nettwork 278 | * 279 | * Once the network is Connected you may use the statusEmitter to listen for 280 | * player status events, query the media databases of devices using the db 281 | * service (or specifically query the localdb or remotedb). 282 | */ 283 | get state() { 284 | return this.#state; 285 | } 286 | 287 | /** 288 | * Check if the network has been configured. You cannot connect to the network 289 | * until it has been configured. 290 | */ 291 | get isConfigured() { 292 | return this.#config !== null; 293 | } 294 | 295 | /** 296 | * Typescript discriminate helper. Refines the type of the network to one 297 | * that reflects the connected status. Useful to avoid having to gaurd the 298 | * service getters from nulls. 299 | */ 300 | isConnected(): this is ConnectedProlinkNetwork { 301 | return this.#state === NetworkState.Connected; 302 | } 303 | 304 | /** 305 | * Get the {@link DeviceManager} service. This service is used to monitor and 306 | * react to devices connecting and disconnecting from the prolink network. 307 | */ 308 | get deviceManager() { 309 | return this.#deviceManager; 310 | } 311 | 312 | /** 313 | * Get the {@link StatusEmitter} service. This service is used to monitor 314 | * status updates on each CDJ. 315 | */ 316 | get statusEmitter() { 317 | // Even though the status emitter service does not need to wait for the 318 | // network to be Connected, it does not make sense to use it unless it is. So 319 | // we artificially return null if we are not connected 320 | return this.#state === NetworkState.Connected ? this.#statusEmitter : null; 321 | } 322 | 323 | /** 324 | * Get the {@link Control} service. This service can be used to control the 325 | * Playstate of CDJs on the network. 326 | */ 327 | get control() { 328 | return this.#connection?.control ?? null; 329 | } 330 | 331 | /** 332 | * Get the {@link Database} service. This service is used to retrieve 333 | * metadata and listings from devices on the network, automatically choosing the 334 | * best strategy to access the data. 335 | */ 336 | get db() { 337 | return this.#connection?.database ?? null; 338 | } 339 | 340 | /** 341 | * Get the {@link LocalDatabase} service. This service is used to query and sync 342 | * metadata that is downloaded directly from the rekordbox database present 343 | * on media connected to the CDJs. 344 | */ 345 | get localdb() { 346 | return this.#connection?.localdb ?? null; 347 | } 348 | 349 | /** 350 | * Get the {@link RemoteDatabase} service. This service is used to query 351 | * metadata directly from the database service running on Rekordbox and the CDJs 352 | * themselves. 353 | * 354 | * NOTE: To use this service to access the CDJ remote database service, the 355 | * Virtual CDJ must report itself as an ID between 1 and 6. This means 356 | * there cannot be four physical CDJs on the network to access any CDJs 357 | * remote database. 358 | */ 359 | get remotedb() { 360 | return this.#connection?.remotedb ?? null; 361 | } 362 | 363 | /** 364 | * Get (and initialize) the {@link MixstatusProcessor} service. This service can 365 | * be used to monitor the 'status' of devices on the network as a whole. 366 | */ 367 | get mixstatus() { 368 | if (this.#connection === null) { 369 | return null; 370 | } 371 | 372 | // Delay initialization of the mixstatus processor so that we don't consume 373 | // status events unless we actually want to. 374 | if (this.#mixstatus === null) { 375 | this.#mixstatus = new MixstatusProcessor(); 376 | this.#statusEmitter.on('status', s => this.#mixstatus?.handleState(s)); 377 | } 378 | 379 | return this.#mixstatus; 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/mixstatus/index.ts: -------------------------------------------------------------------------------- 1 | import StrictEventEmitter from 'strict-event-emitter-types'; 2 | 3 | import {EventEmitter} from 'events'; 4 | 5 | import {CDJStatus, DeviceID, MixstatusMode} from 'src/types'; 6 | import {bpmToSeconds} from 'src/utils'; 7 | 8 | import {isPlaying, isStopping} from './utils'; 9 | 10 | export interface MixstatusConfig { 11 | /** 12 | * Selects the mixstatus reporting mode 13 | */ 14 | mode: MixstatusMode; 15 | /** 16 | * Specifies the duration in seconds that no tracks must be on air. This can 17 | * be thought of as how long 'air silence' is reasonable in a set before a 18 | * separate one is considered have begun. 19 | * 20 | * @default 30 (half a minute) 21 | */ 22 | timeBetweenSets: number; 23 | /** 24 | * Indicates if the status objects reported should have their on-air flag 25 | * read. Setting this to false will degrade the functionality of the processor 26 | * such that it will not consider the value of isOnAir and always assume CDJs 27 | * are live. 28 | * 29 | * @default true 30 | */ 31 | useOnAirStatus: boolean; 32 | /** 33 | * Configures how many beats a track may not be live or playing for it to 34 | * still be considered active. 35 | * 36 | * @default 8 (two bars) 37 | */ 38 | allowedInterruptBeats: number; 39 | /** 40 | * Configures how many beats the track must consecutively be playing for 41 | * (since the beat it was cued at) until the track is considered to be 42 | * active. 43 | * 44 | * Used for MixstatusMode.SmartTiming 45 | * 46 | * @default 128 (2 phrases) 47 | */ 48 | beatsUntilReported: number; 49 | } 50 | 51 | const defaultConfig: MixstatusConfig = { 52 | mode: MixstatusMode.SmartTiming, 53 | timeBetweenSets: 30, 54 | allowedInterruptBeats: 8, 55 | beatsUntilReported: 128, 56 | useOnAirStatus: true, 57 | }; 58 | 59 | /** 60 | * The interface the mix status event emitter should follow 61 | */ 62 | interface MixstatusEvents { 63 | /** 64 | * Fired when a track is considered to be on-air and is being heard by the 65 | * audience 66 | */ 67 | nowPlaying: (state: CDJStatus.State) => void; 68 | /** 69 | * Fired when a track has stopped and is completely offair 70 | */ 71 | stopped: (opt: {deviceId: DeviceID}) => void; 72 | /** 73 | * Fired when a DJ set first starts 74 | */ 75 | setStarted: () => void; 76 | /** 77 | * Fired when tracks have been stopped 78 | */ 79 | setEnded: () => void; 80 | } 81 | 82 | type Emitter = StrictEventEmitter; 83 | 84 | /** 85 | * MixstatusProcessor is a configurable processor which when fed device state 86 | * will attempt to accurately determine events that happen within the DJ set. 87 | * 88 | * The following events are fired: 89 | * 90 | * - nowPlaying: The track is considered playing and on air to the audience. 91 | * - stopped: The track was stopped / paused / went off-air. 92 | * 93 | * Additionally the following non-track status are reported: 94 | * 95 | * - setStarted: The first track has begun playing. 96 | * - setEnded: The TimeBetweenSets has passed since any tracks were live. 97 | * 98 | * See Config for configuration options. 99 | * 100 | * Config options may be changed after the processor has been created and is 101 | * actively receiving state updates. 102 | */ 103 | export class MixstatusProcessor { 104 | /** 105 | * Used to fire track mix status events 106 | */ 107 | #emitter: Emitter = new EventEmitter(); 108 | /** 109 | * Records the most recent state of each player 110 | */ 111 | #lastState = new Map(); 112 | /** 113 | * Records when each device last started playing a track 114 | */ 115 | #lastStartTime = new Map(); 116 | /** 117 | * Records when a device entered a 'may stop' state. If it's in the state for 118 | * long enough it will be reported as stopped. 119 | */ 120 | #lastStoppedTimes = new Map(); 121 | /** 122 | * Records which players have been reported as 'live' 123 | */ 124 | #livePlayers = new Set(); 125 | /** 126 | * Incidates if we're currentiny in an active DJ set 127 | */ 128 | #isSetActive = false; 129 | /** 130 | * When we are waiting for a set to end, use this to cancel the timer. 131 | */ 132 | #cancelSetEnding?: () => void; 133 | /** 134 | * The configuration for this instance of the processor 135 | */ 136 | #config: MixstatusConfig; 137 | 138 | constructor(config?: Partial) { 139 | this.#config = {...defaultConfig, ...config}; 140 | } 141 | 142 | /** 143 | * Update the configuration 144 | */ 145 | configure(config?: Partial) { 146 | this.#config = {...this.#config, ...config}; 147 | } 148 | 149 | // Bind public event emitter interface 150 | on: Emitter['on'] = this.#emitter.addListener.bind(this.#emitter); 151 | off: Emitter['off'] = this.#emitter.removeListener.bind(this.#emitter); 152 | once: Emitter['once'] = this.#emitter.once.bind(this.#emitter); 153 | 154 | /** 155 | * Helper to account for the useOnAirStatus config. If not configured 156 | * with this flag the state will always be determined as on air. 157 | */ 158 | #onAir = (state: CDJStatus.State) => 159 | this.#config.useOnAirStatus ? state.isOnAir : true; 160 | 161 | /** 162 | * Report a player as 'live'. Will not report the state if the player has 163 | * already previously been reported as live. 164 | */ 165 | #promotePlayer = (state: CDJStatus.State) => { 166 | const {deviceId} = state; 167 | 168 | if (!this.#onAir(state) || !isPlaying(state)) { 169 | return; 170 | } 171 | 172 | if (this.#livePlayers.has(deviceId)) { 173 | return; 174 | } 175 | 176 | if (!this.#isSetActive) { 177 | this.#isSetActive = true; 178 | this.#emitter.emit('setStarted'); 179 | } 180 | 181 | if (this.#cancelSetEnding) { 182 | this.#cancelSetEnding(); 183 | } 184 | 185 | this.#livePlayers.add(deviceId); 186 | 187 | this.#emitter.emit('nowPlaying', state); 188 | }; 189 | 190 | /** 191 | * Locate the player that has been playing for the longest time and is onair, 192 | * and report that device as now playing. 193 | */ 194 | #promoteNextPlayer = () => { 195 | const longestPlayingId = [...this.#lastStartTime.entries()] 196 | .map(([deviceId, startedAt]) => ({ 197 | deviceId, 198 | startedAt, 199 | state: this.#lastState.get(deviceId), 200 | })) 201 | .filter(s => !this.#livePlayers.has(s.deviceId)) 202 | .filter(s => s.state && isPlaying(s.state)) 203 | .sort((a, b) => b.startedAt - a.startedAt) 204 | .pop()?.deviceId; 205 | 206 | // No other players currently playing? 207 | if (longestPlayingId === undefined) { 208 | this.#setMayStop(); 209 | return; 210 | } 211 | 212 | // We know this value is available since we have a live player playing ID 213 | const nextPlayerState = this.#lastState.get(longestPlayingId)!; 214 | this.#promotePlayer(nextPlayerState); 215 | }; 216 | 217 | #markPlayerStopped = ({deviceId}: CDJStatus.State) => { 218 | this.#lastStoppedTimes.delete(deviceId); 219 | this.#lastStartTime.delete(deviceId); 220 | this.#livePlayers.delete(deviceId); 221 | 222 | this.#promoteNextPlayer(); 223 | this.#emitter.emit('stopped', {deviceId}); 224 | }; 225 | 226 | #setMayStop = async () => { 227 | // We handle the set ending interrupt as a async timeout as in the case with 228 | // a set ending, the DJ may immediately turn off the CDJs, stopping state 229 | // packets meaning we can't process on a heartbeat. 230 | if (!this.#isSetActive) { 231 | return; 232 | } 233 | 234 | // If any tracks are still playing the set has not ended 235 | if ([...this.#lastState.values()].some(s => isPlaying(s) && this.#onAir(s))) { 236 | return; 237 | } 238 | 239 | const shouldEnd = await new Promise(resolve => { 240 | const endTimeout = setTimeout( 241 | () => resolve(true), 242 | this.#config.timeBetweenSets * 1000 243 | ); 244 | this.#cancelSetEnding = () => { 245 | clearTimeout(endTimeout); 246 | resolve(false); 247 | }; 248 | }); 249 | 250 | this.#cancelSetEnding = undefined; 251 | 252 | if (!shouldEnd || !this.#isSetActive) { 253 | return; 254 | } 255 | 256 | this.#emitter.emit('setEnded'); 257 | }; 258 | 259 | /** 260 | * Called to indicate that we think this player may be the first one to start 261 | * playing. Will check if no other players are playing, if so it will report 262 | * the player as now playing. 263 | */ 264 | #playerMayBeFirst = (state: CDJStatus.State) => { 265 | const otherPlayersPlaying = [...this.#lastState.values()] 266 | .filter(otherState => otherState.deviceId !== state.deviceId) 267 | .some(otherState => this.#onAir(otherState) && isPlaying(otherState)); 268 | 269 | if (otherPlayersPlaying) { 270 | return; 271 | } 272 | 273 | this.#promotePlayer(state); 274 | }; 275 | 276 | /** 277 | * Called when the player is in a state where it is no longer playing, but 278 | * may come back onair. Examples are slip pause, or 'cutting' a track on the 279 | * mixer taking it offair. 280 | */ 281 | #playerMayStop = ({deviceId}: CDJStatus.State) => { 282 | this.#lastStoppedTimes.set(deviceId, Date.now()); 283 | }; 284 | 285 | /** 286 | * Called to indicate that a device has reported a different playState than 287 | * it had previously reported. 288 | */ 289 | #handlePlaystateChange = (lastState: CDJStatus.State, state: CDJStatus.State) => { 290 | const {deviceId} = state; 291 | 292 | const isFollowingMaster = 293 | this.#config.mode === MixstatusMode.FollowsMaster && state.isMaster; 294 | 295 | const nowPlaying = isPlaying(state); 296 | const wasPlaying = isPlaying(lastState); 297 | 298 | const isNowPlaying = nowPlaying && !wasPlaying; 299 | 300 | // Was this device in a 'may stop' state and it has begun on-air playing 301 | // again? 302 | if (this.#lastStoppedTimes.has(deviceId) && nowPlaying && this.#onAir(state)) { 303 | this.#lastStoppedTimes.delete(deviceId); 304 | return; 305 | } 306 | 307 | if (isNowPlaying && isFollowingMaster) { 308 | this.#promotePlayer(state); 309 | } 310 | 311 | if (isNowPlaying) { 312 | this.#lastStartTime.set(deviceId, Date.now()); 313 | this.#playerMayBeFirst(state); 314 | return; 315 | } 316 | 317 | if (wasPlaying && isStopping(state)) { 318 | this.#markPlayerStopped(state); 319 | return; 320 | } 321 | 322 | if (wasPlaying && !nowPlaying) { 323 | this.#playerMayStop(state); 324 | } 325 | }; 326 | 327 | #handleOnairChange = (state: CDJStatus.State) => { 328 | const {deviceId} = state; 329 | 330 | // Player may have just been brought on with nothing else playing 331 | this.#playerMayBeFirst(state); 332 | 333 | if (!this.#livePlayers.has(deviceId)) { 334 | return; 335 | } 336 | 337 | if (!this.#onAir(state)) { 338 | this.#playerMayStop(state); 339 | return; 340 | } 341 | 342 | // Play has come back onair 343 | this.#lastStoppedTimes.delete(deviceId); 344 | }; 345 | 346 | /** 347 | * Feed a CDJStatus state object to the mix state processor 348 | */ 349 | handleState(state: CDJStatus.State) { 350 | const {deviceId, playState} = state; 351 | 352 | const lastState = this.#lastState.get(deviceId); 353 | this.#lastState.set(deviceId, state); 354 | 355 | // If this is the first time we've heard from this CDJ, and it is on air 356 | // and playing, report it immediately. This is different from reporting the 357 | // first playing track, as the CDJ will have already sent many states. 358 | if (lastState === undefined && this.#onAir(state) && isPlaying(state)) { 359 | this.#lastStartTime.set(deviceId, Date.now()); 360 | this.#playerMayBeFirst(state); 361 | return; 362 | } 363 | 364 | // Play state has changed since this play last reported 365 | if (lastState && lastState.playState !== playState) { 366 | this.#handlePlaystateChange(lastState, state); 367 | } 368 | 369 | if (lastState && this.#onAir(lastState) !== this.#onAir(state)) { 370 | this.#handleOnairChange(state); 371 | } 372 | 373 | // Are we simply following master? 374 | if ( 375 | this.#config.mode === MixstatusMode.FollowsMaster && 376 | lastState?.isMaster === false && 377 | state.isMaster 378 | ) { 379 | this.#promotePlayer(state); 380 | return; 381 | } 382 | 383 | // If a device has been playing for the required number of beats, we may be 384 | // able to report it as live 385 | const startedAt = this.#lastStartTime.get(deviceId); 386 | const requiredPlayTime = 387 | this.#config.beatsUntilReported * 388 | bpmToSeconds(state.trackBPM!, state.sliderPitch) * 389 | 1000; 390 | 391 | if ( 392 | this.#config.mode === MixstatusMode.SmartTiming && 393 | startedAt !== undefined && 394 | requiredPlayTime <= Date.now() - startedAt 395 | ) { 396 | this.#promotePlayer(state); 397 | } 398 | 399 | // If a device has been in a 'potentially stopped' state for long enough, 400 | // we can mark the track as truly stopped. 401 | const stoppedAt = this.#lastStoppedTimes.get(deviceId); 402 | const requiredStopTime = 403 | this.#config.allowedInterruptBeats * 404 | bpmToSeconds(state.trackBPM!, state.sliderPitch) * 405 | 1000; 406 | 407 | if (stoppedAt !== undefined && requiredStopTime <= Date.now() - stoppedAt) { 408 | this.#markPlayerStopped(state); 409 | } 410 | } 411 | 412 | /** 413 | * Manually reports the track that has been playing the longest which has not 414 | * yet been reported as live. 415 | */ 416 | triggerNextTrack() { 417 | this.#promoteNextPlayer(); 418 | } 419 | } 420 | --------------------------------------------------------------------------------