tv-automation-quantel-gateway-client
Version:
Quantel gateway client library
623 lines • 25.6 kB
JavaScript
"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