UNPKG

tv-automation-quantel-gateway-client

Version:
623 lines 25.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.QuantelGateway = void 0; const tslib_1 = require("tslib"); const node_fetch_1 = tslib_1.__importDefault(require("node-fetch")); const http_1 = require("http"); const https_1 = require("https"); const events_1 = require("events"); const DEFAULT_CHECK_STATUS_INTERVAL = 3000; const DEFAULT_CALL_TIMEOUT = 5000; const MAX_FREE_SOCKETS = 5; const MAX_SOCKETS_PER_HOST = 5; const MAX_ALL_SOCKETS = 25; const HTTP_KEEP_ALIVE = 60 * 1000; const HTTP_TIMEOUT = 30 * 1000; const HTTP_FREE_SOCKET_TIMEOUT = HTTP_TIMEOUT + 1000; const gatewayHTTPAgent = new http_1.Agent({ keepAlive: true, maxFreeSockets: MAX_FREE_SOCKETS, maxSockets: MAX_SOCKETS_PER_HOST, maxTotalSockets: MAX_ALL_SOCKETS, keepAliveMsecs: HTTP_KEEP_ALIVE, timeout: HTTP_FREE_SOCKET_TIMEOUT, }); const gatewayHTTPSAgent = new https_1.Agent({ keepAlive: true, maxFreeSockets: MAX_FREE_SOCKETS, maxSockets: MAX_SOCKETS_PER_HOST, maxTotalSockets: MAX_ALL_SOCKETS, keepAliveMsecs: HTTP_KEEP_ALIVE, timeout: HTTP_FREE_SOCKET_TIMEOUT, }); const literal = (t) => t; /** * Remote connection to a [Sofie Quantel Gateway](https://github.com/nrkno/sofie-quantel-gateway). * Create and initialize a new connection as follows: * * const quantelClient = new QuantelGateway() * await quantelCient.init( * 'quantel.gateway.url:port', 'quantel.isa.url', undefined, 'default', serverID) * * If the serverID is not known, before calling `init()` request the details of all servers: * * await quantelClient.connectToISA('quantel.isa.url') * const servers = await quantelClient.getServers('default') * * Then initialize the client as above. * * Once finished with the class, call `dispose()`. */ class QuantelGateway extends events_1.EventEmitter { /** Create a Quantel Gateway client. */ constructor(config) { var _a, _b; super(); this._checkStatusInterval = DEFAULT_CHECK_STATUS_INTERVAL; this._callTimeout = DEFAULT_CALL_TIMEOUT; this._initialized = false; this._ISAUrls = []; this._statusMessage = 'Initializing...'; // null = all good this._monitorPorts = {}; this._connected = false; this._checkStatusInterval = (_a = config === null || config === void 0 ? void 0 : config.checkStatusInterval) !== null && _a !== void 0 ? _a : DEFAULT_CHECK_STATUS_INTERVAL; this._callTimeout = (_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : DEFAULT_CALL_TIMEOUT; } /** * Initialize a Quantel Gateway client, making the required connections. * * in the event that connection to one of them fails. * @param gatewayUrl Location of the associated Quantel Gateway. * @param ISAUrls Locations of the ISA managers (in order of importance). * Multiple entries means that there are a master and one or several slave ISA's. * In the event of failure of the master, the slaves will be tried in order by the Quantel gateway. * @param zoneId Zone identifier, or `undefined` for default. * @param serverId Identifier of the server to be controlled. */ async init(gatewayUrl, ISAUrls, zoneId, serverId) { this._initialized = false; // in case we are called again this._cachedServer = undefined; // reset in the event of a second calling this._gatewayUrl = gatewayUrl.replace(/\/$/, ''); // trim trailing slash if (!this._gatewayUrl.match(/http/)) this._gatewayUrl = 'http://' + this._gatewayUrl; // Connect to ISA(s): await this.connectToISA(ISAUrls); this._zoneId = zoneId || 'default'; // TODO: this is not implemented yet in Quantel gw: // const zones = await this.getZones() // const zone = _.find(zones, zone => zone.zoneName === this._zoneId) // if (!zone) throw new Error(`Zone ${this._zoneId} not found!`) await this.setServerId(serverId); this._initialized = true; } get checkStatusInterval() { return this._checkStatusInterval; } /** * Request that the Quantel Gateway connects to the given ISA manager. * @param ISAUrls Locations of the ISA managers (in order of importance). Multiple entries means that there are a master and one or several slave ISA's. * @returns Details of the connection created. */ async connectToISA(ISAUrls) { this._ISAUrls = Array.isArray(ISAUrls) ? ISAUrls : ISAUrls ? [ISAUrls] : []; return await this.reconnectToISA(); } async reconnectToISA() { const ISAUrl = this._formattedISAUrl; return await this._ensureGoodResponse(this.sendRaw('POST', `connect/${encodeURIComponent(ISAUrl)}`)); } /** * Sefely dispose of the resources used by this client, stopping monitors. */ dispose() { if (this._monitorInterval) { clearInterval(this._monitorInterval); } } /** * Start the process of repeatedly monitoring the status of the attached * Quantel Gateway and onwards to an ISA manager. * @param callbackOnStatusChange Callback function called when * the connection status through to the ISA manager changes. */ monitorServerStatus(callbackOnStatusChange) { const getServerStatus = async () => { try { this._connected = false; if (!this._gatewayUrl) return `Gateway URL not set`; if (!this._serverId) return `QuantelGatewayClient.serverId not set`; const server = await this.getServer(true); if (!server) return `Server ${this._serverId} not found on ISA`; if (server.down) return `Server ${server.ident} is down`; this._connected = true; const serverErrors = []; for (const [monitorPortId, monitorPort] of Object.entries(this._monitorPorts)) { const portExists = server.portNames ? server.portNames.find((portName) => portName === monitorPortId) : undefined; const realPortNames = server.portNames ? server.portNames.filter(Boolean) : []; // Filter out falsy names if (!portExists && // our port is NOT set up on server realPortNames.length === (server.numChannels || 0) // There is no more room on server ) { serverErrors.push(`Not able to assign port "${monitorPortId}", due to all ports being already used`); } else { for (const monitorChannel of monitorPort.channels) { const channelPort = (server.chanPorts || [])[monitorChannel]; if (channelPort && // The channel is assigned to a port channelPort !== monitorPortId // The channel is NOT assigned to our port! ) { serverErrors.push(`Not able to assign channel to port "${monitorPortId}", the channel ${monitorChannel} is already assigned to another port "${channelPort}"!`); } } } } if (serverErrors.length) return serverErrors.join(', '); if (!this._initialized) return `Not initialized`; return null; // all good } catch (e) { return `Error when monitoring status: ${(e === null || e === void 0 ? void 0 : e.message) || e.toString()}`; } }; const checkServerStatus = () => { getServerStatus() .then((statusMessage) => { if (statusMessage !== this._statusMessage) { this._statusMessage = statusMessage; callbackOnStatusChange(statusMessage === null, statusMessage); } }) .catch((e) => this.emit('error', e)); }; this._monitorInterval = setInterval(() => { checkServerStatus(); }, this._checkStatusInterval); checkServerStatus(); // also run one right away } /** Is the client connected somehow? */ get connected() { return this._connected; } /** * Description of the status of the connection. * @returns Current status, or `null` if all is good. */ get statusMessage() { return this._statusMessage; } /** Is this client initialized? */ get initialized() { return this._initialized; } /** Location of the Quantel Gateway this client targets. */ get gatewayUrl() { return this._gatewayUrl || ''; } /** The Location(s) of the ISA Manager(s) the gateway can connect to. (comma-separated string) */ get ISAUrl() { return this._formattedISAUrl; } get ISAUrls() { return this._ISAUrls; } /** Get the zone identifier set for this client. */ get zoneId() { return this._zoneId || 'default'; } /** Get the server to be controlled by this client. */ get serverId() { return this._serverId; } /** Set the server to be controlled by this client. */ async setServerId(serverId) { this._serverId = serverId; // If the server is not set, skip this check. // (In some cases, the consumer might not want to provide a serverId (like when only we only want to search, never copy)) if (this._serverId) { const server = await this.getServer(true); if (!server) throw new Error(`Server ${this._serverId} not found on ISA!`); } } /** * List details of all zones the ISA Manager is connected to. * @returns Details of zones all connected zones. */ async getZones() { return this._ensureGoodResponse(this.sendRaw('GET', '')); } /** * Get a list of all servers availabe within a zone. * @param zoneId Zone identifier. Omit for `default`. * @returns Details of all the servers within a zone. */ async getServers(zoneId) { if (!zoneId) { zoneId = 'default'; } return this._ensureGoodResponse(this.sendRaw('GET', `${zoneId}/server`)); } /** Return the (possibly cached) server */ async getServer(disableCache = false) { var _a; // Invalidate the cache? if (disableCache || ((_a = this._cachedServer) === null || _a === void 0 ? void 0 : _a.ident) !== this._serverId) { this._cachedServer = undefined; } if (this._cachedServer !== undefined) return this._cachedServer; if (!this._serverId) throw new Error(`QuantelGatewayClient.serverId not set`); const servers = await this.getServers(this._zoneId || 'default'); const server = servers.find((s) => { return s.ident === this._serverId; }) || null; this._cachedServer = server ? server : undefined; return server; } /** * Retrieve details of an existing port. * @param portId Identifier for the port to query. * @returns Status of the port, including timings and current playing offset. */ async getPort(portId) { try { return await this.sendServer('GET', `port/${portId}`); } catch (e) { if (this._isNotFoundAThing(e)) return null; throw e; } } /** * Create (allocate) a new port (logical device) and connect it to a channel * (physical SDI connector). * @param portId Name of the port to create. * @param channelId Number of the physical channel to connect the port to. * "returns" */ async createPort(portId, channelId) { return this.sendServer('PUT', `port/${portId}/channel/${channelId}`); } /** * Release (remove) an allocated port. This allows other applications to grab the * associated channels. * @param portId Identifier of port to remove. * @returns Reported status of the removal. */ async releasePort(portId) { return this.sendServer('DELETE', `port/${portId}`); } /** * Reset a port, removing all fragments and resetting the playhead of the port. * The port persists after reset, maintaining ownership of its associated channels. * @returns Status of the release. */ async resetPort(portId) { return this.sendServer('POST', `port/${portId}/reset`); } /** * Get infomation about a clip. * @param clipId Identifier for the clip to query. * @returns Resolves with clip details or `null` if the clip is not found. */ async getClip(clipId) { try { return await this.sendZone('GET', `clip/${clipId}`); } catch (e) { if (this._isNotFoundAThing(e)) return null; throw e; } } /** * Search for a clip using search query parameters, e.g. `{ Title: 'Trump loses hair' }` * @param searchQuery Details of the requested search. * @returns A list of zero or more search summaries, one for each matching clip. */ async searchClip(searchQuery) { return this.sendZone('GET', `clip`, searchQuery); } async getClipFragments(clipId, inPoint, outPoint) { if (inPoint !== undefined && outPoint !== undefined) { return this.sendZone('GET', `clip/${clipId}/fragments/${inPoint}-${outPoint}`); } else { return this.sendZone('GET', `clip/${clipId}/fragments`); } } /** * Load the given fragments onto a port. * @param portId Name of the port to load fragments onto. * @param fragments Fragments to load. * @param offset Specify an offset from that specified in the fragment to load the fragment. * @returns Status of the port load request. */ async loadFragmentsOntoPort(portId, fragments, offset) { const response = this.sendServer('POST', `port/${portId}/fragments`, { offset: offset, }, fragments); return response; } /** Query the port for which fragments are loaded. */ async getFragmentsOnPort(portId, rangeStart, rangeEnd) { return this.sendServer('GET', `port/${portId}/fragments`, { start: rangeStart, finish: rangeEnd, }); // /:zoneID/server/:serverID/port/:portID/fragments(?start=:start&finish=:finish) } /** * Start playing on a port at its current offset. * @param portId Name of the port to press play on. * @throws If the play operation was successful. */ async portPlay(portId) { const response = await this.sendServer('POST', `port/${portId}/trigger/START`); if (!response.success) throw Error(`Quantel trigger start: Server returned success=${response.success}`); return response; } /** * Stop (pause) playback on a port. If `stopAtFrame` is provided, the playback * will stop at the frame specified. Otherwise playback will be paused now. * @param portId Name of the port to pause. * @param stopAtFrame Optional frame-in-the-future at which to stop. * @throws If the pause operation was not successful. */ async portStop(portId, stopAtFrame) { const response = await this.sendServer('POST', `port/${portId}/trigger/STOP`, { offset: stopAtFrame, }); if (!response.success) throw Error(`Quantel trigger stop: Server returned success=${response.success}`); return response; } /** Jump directly to a frame. This might cause flicker on the output, as the frames * haven't been preloaded. * @param portId Name of port to jump on. * @param jumpToFrame Offset of the jump-to point. * @throws If the jump was not successful. */ async portHardJump(portId, jumpToFrame) { const response = await this.sendServer('POST', `port/${portId}/trigger/JUMP`, { offset: jumpToFrame, }); if (!response.success) throw Error(`Quantel hard jump: Server returned success=${response.success}`); return response; } /** * Prepare a jump to a frame. This ensures that those frames are preloaded and ready * to play. * @param portId Name of the port to prepare a jump on. * @param jumpToFrame Offset to set a jump point to. * @throws If setting the jump was not successful. */ async portPrepareJump(portId, jumpToFrame) { const response = await this.sendServer('PUT', `port/${portId}/jump`, { offset: jumpToFrame, }); if (!response.success) throw Error(`Quantel prepare jump: Server returned success=${response.success}`); return response; } /** * After preparing a jump, trigger the jump. * @portId Name of the port to trigger a jump on. * @throws If the jump was not successful. */ async portTriggerJump(portId) { const response = await this.sendServer('POST', `port/${portId}/trigger/JUMP`); if (!response.success) throw Error(`Quantel trigger jump: Server returned success=${response.success}`); return response; } /** * Clear all fragments from a port. * If rangeStart and rangeEnd is provided, will clear the fragments for that time range. * If not, the fragments up until (but not including) the playhead, will be cleared. * * _Dragons_: Including the current offset or end of data inside the range can lead to * unexpected behaviour. * @param portId Name of the port to clear fragments from. * @param rangeStart Start of range to clear fragments from. * @param rangeEnd End range to clear fragments to. * @returns Details of how much was wiped. * @throws If the fragments were not wiped. */ async portClearFragments(portId, rangeStart, rangeEnd) { const response = await this.sendServer('DELETE', `port/${portId}/fragments`, { start: rangeStart, finish: rangeEnd, }); if (!response.wiped) throw Error(`Quantel clear port: Server returned wiped=${response.wiped}`); return response; } /** * Set the ports that are monitored for changes. * @param monitorPorts Dictionary of ports monitored for status change. */ setMonitoredPorts(monitorPorts) { this._monitorPorts = monitorPorts; } /** * Request that the Quantel gateway kills itself. * If running in Docker configured to auto-restart, calling this method will * cause the gateway to automatically restart. */ async kill() { await this.sendBase('POST', 'kill/me/if/you/are/sure'); } /** * Request a clone of a clip, either between zones or between servers in the same zone. * The target zone ID is that of the servers the request is sent to. * @param zoneID Source zone ID, for inter-zone copies only. Otherwise `undefined`. * @param clipID Identifier for the source clip. * @param poolID Target pool identifier. * @param priority Priority level, a value between 0 (low) and 15 (high). Default is 8 (standard). * @param history For inter-zone cloning, should provenance be carried along with copy? Default is `true`. * @returns Details of the copy, including a `copyID` clip identifier for the target copy. */ async copyClip(zoneID, clipID, poolID, priority, history) { const response = await this.sendZone('POST', 'copy', undefined, literal({ zoneID, clipID, poolID, priority, history, })); return response; } /** * Requests details of an ongoing or completed copy operation. * Note that if the copy completed some time ago or an associated copy operation * did not exist, this will throw a _Not Found_ exception. * @param copyID Identifier of the target clip. * @returns Details of the progress of the copy. */ async getCopyRemaining(copyID) { const response = await this.sendZone('GET', `copy/${copyID}`); return response; } /** * Get the details of all ongoing copy operations. * @returns List of all ongoing copy operations. */ async getAllCopyOperations() { const response = await this.sendZone('GET', 'copy'); return response; } getHTTPAgents() { return { http: gatewayHTTPAgent, https: gatewayHTTPSAgent, }; } async sendServer(method, resource, queryParameters, bodyData) { if (!this._serverId) throw new Error(`QuantelClient.serverId not set`); return this.sendZone(method, `server/${this._serverId}/${resource}`, queryParameters, bodyData); } async sendZone(method, resource, queryParameters, bodyData) { return this.sendBase(method, `${this._zoneId}/${resource}`, queryParameters, bodyData); } async sendBase(method, resource, queryParameters, bodyData) { if (!this._initialized) { throw new Error('Quantel not initialized yet'); } return this._ensureGoodResponse(this.sendRaw(method, `${resource}`, queryParameters, bodyData)); } async sendRaw(method, resource, queryParameters, bodyData) { const responseBody = await this.sendRawWithTimeout(method, resource, queryParameters, bodyData); if (this._isAnErrorResponse(responseBody) && responseBody.status === 502 && // (responseBody.message + '').match(/first provide a quantel isa/i) // First provide a Quantel ISA connection URL (e.g. POST to /connect) ) { await this.reconnectToISA(); // Then try again: return this.sendRawWithTimeout(method, resource, queryParameters, bodyData); } else { return responseBody; } } async sendRawWithTimeout(method, resource, queryParameters, bodyData) { var _a; const url = this.urlQuery(this._gatewayUrl + '/' + resource, queryParameters); let body = undefined; const headers = { 'keep-alive': `timeout=${Math.ceil(HTTP_KEEP_ALIVE / 1000)}`, }; if (bodyData && typeof bodyData === 'string') { body = bodyData; headers['content-type'] = 'text/plain'; } else if (bodyData) { body = JSON.stringify(bodyData); headers['content-type'] = 'application/json'; } const response = await (0, node_fetch_1.default)(url, { method, agent: (url) => (url.protocol === 'https:' ? gatewayHTTPSAgent : gatewayHTTPAgent), redirect: 'follow', signal: AbortSignal.timeout(this._callTimeout), headers, body, }); if ((_a = response.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.startsWith('application/json')) { return response.json(); } else { return response.text(); } } urlQuery(url, params = {}) { const paramStrs = []; for (const [key, value] of Object.entries(params)) { if (value !== undefined) { paramStrs.push(`${key}=${encodeURIComponent(value.toString())}`); } } const queryString = paramStrs.join('&'); return url + (queryString ? `?${queryString}` : ''); } async _ensureGoodResponse(pResponse, if404ThenNull) { const response = await pResponse; if (this._isAnErrorResponse(response)) { if (response.status === 404) { if (if404ThenNull) { return null; } else { throw new Error(`${response.status} ${response.message}\n${response.stack}`); } } else { throw new Error(`${response.status} ${response.message}\n${response.stack}`); } } return response; } _isAnErrorResponse(response) { const test = response; return !!(test && typeof test === 'object' && Object.prototype.hasOwnProperty.call(test, 'status') && test.status && typeof test.status === 'number' && typeof test.message === 'string' && typeof test.stack === 'string' && test.status !== 200); } _isNotFoundAThing(e) { if (e.message.match(/404/)) { return (e.message || '').match('Not found. Request') === null; } return false; } get _formattedISAUrl() { if (this._ISAUrls.length) { const urls = []; for (const url of this._ISAUrls) { urls.push(url.replace(/^https?:\/\//, '')); // trim any https:// } return urls.join(','); } else { throw new Error('Quantel ISAUrls not set!'); } } } exports.QuantelGateway = QuantelGateway; //# sourceMappingURL=quantelGateway.js.map