@eluvio/elv-client-js 4.2.16 → 4.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/AuthorizationClient.js +1 -6
- package/src/FrameClient.js +23 -2
- package/src/abr_profiles/abr_profile_live_drm.js +0 -10
- package/src/client/ContentAccess.js +54 -64
- package/src/client/LiveConf.js +150 -65
- package/src/client/LiveStream.js +2613 -654
- package/src/client/NTP.js +71 -0
- package/src/live_recording_config_profiles/live_recording_config_default.js +54 -0
- package/src/live_recording_config_profiles/live_stream_profile_full.json +143 -0
- package/testScripts/StreamUpdateLinks.js +95 -0
- package/utilities/LiveOutputs.js +149 -0
- package/utilities/StreamCreate.js +53 -0
- package/utilities/lib/helpers.js +5 -1
- package/utilities/tests/mocks/ElvClient.mock.js +9 -1
- package/utilities/tests/unit/StreamCreate.test.js +39 -0
package/src/client/LiveStream.js
CHANGED
|
@@ -13,6 +13,11 @@ const HttpClient = require("../HttpClient");
|
|
|
13
13
|
const Fraction = require("fraction.js");
|
|
14
14
|
const {ValidateObject, ValidatePresence} = require("../Validation");
|
|
15
15
|
const ContentObjectAudit = require("../ContentObjectAudit");
|
|
16
|
+
const slugify = str => (str || "").toLowerCase().trim().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "");
|
|
17
|
+
const LRCProfile = require("../live_recording_config_profiles/live_recording_config_default");
|
|
18
|
+
const R = require("ramda");
|
|
19
|
+
const UrlJoin = require("url-join");
|
|
20
|
+
const URI = require("urijs");
|
|
16
21
|
|
|
17
22
|
const MakeTxLessToken = async({client, libraryId, objectId, versionHash}) => {
|
|
18
23
|
const tok = await client.authClient.AuthorizationToken({libraryId, objectId,
|
|
@@ -26,13 +31,124 @@ const Sleep = (ms) => {
|
|
|
26
31
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
27
32
|
};
|
|
28
33
|
|
|
34
|
+
const VALID_PLAYOUT_FORMATS = [
|
|
35
|
+
"hls-sample-aes",
|
|
36
|
+
"hls-aes128",
|
|
37
|
+
"hls-fairplay",
|
|
38
|
+
"hls-widevine-cenc",
|
|
39
|
+
"hls-playready-cenc",
|
|
40
|
+
"dash-widevine",
|
|
41
|
+
"hls-clear",
|
|
42
|
+
"dash-clear"
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Converts a list of File objects into an array of file info objects suitable for upload.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} path - The destination path prefix for the files
|
|
49
|
+
* @param {FileList|Array<File>} fileList - The list of File objects to process
|
|
50
|
+
*
|
|
51
|
+
* @returns {Promise<Array<Object>>} - Array of file info objects with path, type, size, mime_type, and data
|
|
52
|
+
*/
|
|
53
|
+
const FileInfo = async ({path, fileList}) => {
|
|
54
|
+
return await Promise.all(
|
|
55
|
+
Array.from(fileList).map(async file => {
|
|
56
|
+
const data = file;
|
|
57
|
+
const filePath = file.webkitRelativePath || file.name;
|
|
58
|
+
return {
|
|
59
|
+
path: UrlJoin(path, filePath).replace(/^\/+/g, ""),
|
|
60
|
+
type: "file",
|
|
61
|
+
size: file.size,
|
|
62
|
+
mime_type: file.type,
|
|
63
|
+
data,
|
|
64
|
+
name: file.name
|
|
65
|
+
};
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const GetStreamProbe = async ({client, libraryId, objectId, streamHref, endpoint}) => {
|
|
71
|
+
client.SetNodes({fabricURIs: [endpoint]});
|
|
72
|
+
|
|
73
|
+
let probe = {};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const probeUrl = await client.Rep({
|
|
77
|
+
libraryId,
|
|
78
|
+
objectId,
|
|
79
|
+
rep: "probe"
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
probe = await client.utils.ResponseToJson(
|
|
83
|
+
await HttpClient.Fetch(probeUrl, {
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
"filename": streamHref,
|
|
86
|
+
"listen": true
|
|
87
|
+
}),
|
|
88
|
+
method: "POST"
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if(probe.errors) {
|
|
93
|
+
throw probe.errors[0];
|
|
94
|
+
}
|
|
95
|
+
} catch(error) {
|
|
96
|
+
if(error.code === "ETIMEDOUT") {
|
|
97
|
+
throw "Stream probe timed out - make sure the stream source is available";
|
|
98
|
+
} else {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
probe.format.filename = streamHref;
|
|
104
|
+
|
|
105
|
+
return probe;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const GetNodeFromStreamData = async ({client, url, nodeId, nodeApi}) => {
|
|
109
|
+
let nodes;
|
|
110
|
+
if(url) {
|
|
111
|
+
const parsedName = url
|
|
112
|
+
.replace("udp://", "https://")
|
|
113
|
+
.replace("rtmp://", "https://")
|
|
114
|
+
.replace("rtp://", "https://")
|
|
115
|
+
.replace("srt://", "https://");
|
|
116
|
+
|
|
117
|
+
// Use regex for hostname extraction — new URL() rejects ports > 65535 (e.g. SRT streams)
|
|
118
|
+
const hostName = parsedName.match(/^https?:\/\/([^/:]+)/)?.[1];
|
|
119
|
+
|
|
120
|
+
client.Log(`Retrieving nodes - matching: ${hostName}`);
|
|
121
|
+
|
|
122
|
+
nodes = await client.SpaceNodes({matchEndpoint: hostName});
|
|
123
|
+
} else if(nodeId) {
|
|
124
|
+
nodes = await client.SpaceNodes({matchNodeId: nodeId});
|
|
125
|
+
|
|
126
|
+
url = nodes?.[0].services.fabric_api?.urls?.[0];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Preserve the original stream URL (including any high port numbers) as a plain string
|
|
130
|
+
const streamHref = nodeApi ?? url;
|
|
131
|
+
|
|
132
|
+
if(nodes.length < 1) {
|
|
133
|
+
throw new Error(`No node found for stream URL: ${streamHref}. Wrong network?`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const node = {
|
|
137
|
+
endpoints: nodes[0].services.fabric_api.urls,
|
|
138
|
+
id: nodes[0].id
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const endpoint = node.endpoints[0];
|
|
142
|
+
|
|
143
|
+
return {node, endpoint, streamHref};
|
|
144
|
+
};
|
|
145
|
+
|
|
29
146
|
const CueInfo = async ({eventId, status}) => {
|
|
30
147
|
let cues;
|
|
31
148
|
try {
|
|
32
149
|
const lroStatusResponse = await this.utils.ResponseToJson(
|
|
33
150
|
await HttpClient.Fetch(status.lro_status_url)
|
|
34
151
|
);
|
|
35
|
-
console.log("lroStatusResponse", lroStatusResponse);
|
|
36
152
|
cues = lroStatusResponse.custom.cues;
|
|
37
153
|
} catch(error) {
|
|
38
154
|
console.log("LRO status failed", error);
|
|
@@ -61,6 +177,447 @@ const CueInfo = async ({eventId, status}) => {
|
|
|
61
177
|
return {eventStart, eventEnd, eventId};
|
|
62
178
|
};
|
|
63
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Create a live stream object
|
|
182
|
+
*
|
|
183
|
+
* @methodGroup Live Stream
|
|
184
|
+
* @namedParams
|
|
185
|
+
* @param {string} libraryId - ID of the library for the new live stream object
|
|
186
|
+
* @param {string=} objectId - ID of the object
|
|
187
|
+
* @param {string} url - Source stream URL
|
|
188
|
+
* @param {boolean=} finalize - If enabled, object will be finalized after creation (default: true)
|
|
189
|
+
* @param {LiveRecordingConfig=} liveRecordingConfig - Configuration profile for the live stream including recording, playout, and transcoding settings
|
|
190
|
+
*
|
|
191
|
+
* @param {Object=} options - Additional options for customizing a live stream
|
|
192
|
+
* @param {string=} options.name - Name of the live stream
|
|
193
|
+
* @param {string=} options.displayTitle - Display title for the live stream
|
|
194
|
+
* @param {string=} options.description - Description for the live stream
|
|
195
|
+
* @param {Array<string>=} options.accessGroups - Access group addresses to receive 'manage' permissions
|
|
196
|
+
* @param {string=} options.permission - Permission level to set on the object
|
|
197
|
+
* @param {boolean=} options.linkToSite - If enabled, will create a link in the live stream site
|
|
198
|
+
* @param {boolean=} options.initializeDrm - If enabled, will initialize DRM for the object
|
|
199
|
+
* @param {string=} options.ingressNodeId - ID of the ingress node used for stream allocation (required for non-public nodes)
|
|
200
|
+
*
|
|
201
|
+
* @return {Promise<Object>} - Object containing objectId, libraryId, writeToken, and hash if finalized
|
|
202
|
+
*/
|
|
203
|
+
exports.StreamCreate = async function({
|
|
204
|
+
libraryId,
|
|
205
|
+
objectId,
|
|
206
|
+
url,
|
|
207
|
+
finalize=true,
|
|
208
|
+
liveRecordingConfig,
|
|
209
|
+
options={}
|
|
210
|
+
}) {
|
|
211
|
+
const defaultName = `LIVE STREAM - ${new Date().toISOString().slice(0, 10)}`;
|
|
212
|
+
const existingObject = !!objectId;
|
|
213
|
+
let contentType;
|
|
214
|
+
let adminGroups = options.accessGroups ?? [];
|
|
215
|
+
|
|
216
|
+
// Retrieve live stream content type
|
|
217
|
+
try {
|
|
218
|
+
const tenantId = await this.userProfileClient.TenantContractId();
|
|
219
|
+
|
|
220
|
+
const tenantMeta = await this.ContentObjectMetadata({
|
|
221
|
+
libraryId: tenantId.replace("iten", "ilib"),
|
|
222
|
+
objectId: tenantId.replace("iten", "iq__"),
|
|
223
|
+
metadataSubtree: "public",
|
|
224
|
+
select: [
|
|
225
|
+
"content_types/live_stream"
|
|
226
|
+
]
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const tenantContentAdminGroup = await this.ContentAdminGroup({tenantContractId: tenantId});
|
|
230
|
+
adminGroups = adminGroups.concat(tenantContentAdminGroup ?? []);
|
|
231
|
+
|
|
232
|
+
contentType = tenantMeta.content_types?.live_stream;
|
|
233
|
+
|
|
234
|
+
if(!contentType) {
|
|
235
|
+
throw new Error(`No content type configured for tenant ${tenantId}`);
|
|
236
|
+
}
|
|
237
|
+
} catch(error) {
|
|
238
|
+
console.error("Unable to load tenant data", error);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let editResponse;
|
|
242
|
+
if(objectId) {
|
|
243
|
+
// Edit existing object
|
|
244
|
+
editResponse = await this.EditContentObject({
|
|
245
|
+
libraryId,
|
|
246
|
+
objectId
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// If no new URL is provided, use value from saved config
|
|
250
|
+
if(!url) {
|
|
251
|
+
url = await this.ContentObjectMetadata({
|
|
252
|
+
libraryId,
|
|
253
|
+
objectId,
|
|
254
|
+
metadataSubtree: "live_recording_config/url"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
// Create new object
|
|
259
|
+
editResponse = await this.CreateContentObject({
|
|
260
|
+
libraryId,
|
|
261
|
+
options: {
|
|
262
|
+
type: contentType
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
objectId = editResponse.objectId;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const {writeToken} = editResponse;
|
|
269
|
+
const {
|
|
270
|
+
accessGroup,
|
|
271
|
+
name=defaultName,
|
|
272
|
+
displayTitle,
|
|
273
|
+
description,
|
|
274
|
+
permission="editable",
|
|
275
|
+
ingressNodeId,
|
|
276
|
+
initializeDrm=true
|
|
277
|
+
} = options;
|
|
278
|
+
|
|
279
|
+
if(!liveRecordingConfig) {
|
|
280
|
+
liveRecordingConfig = {};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const playoutFormats = liveRecordingConfig?.playout_config?.playout_formats;
|
|
284
|
+
if(playoutFormats) {
|
|
285
|
+
const invalid = playoutFormats.filter(f => !VALID_PLAYOUT_FORMATS.includes(f));
|
|
286
|
+
if(invalid.length > 0) {
|
|
287
|
+
throw new Error(`Invalid playout_formats: ${invalid.join(", ")}. Valid values: ${VALID_PLAYOUT_FORMATS.join(", ")}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
liveRecordingConfig.url = url;
|
|
292
|
+
liveRecordingConfig.ingress_node_id = ingressNodeId;
|
|
293
|
+
|
|
294
|
+
// Add access group permissions
|
|
295
|
+
await Promise.all(
|
|
296
|
+
adminGroups.filter(el => !!el).map(async(group) => {
|
|
297
|
+
if(!group) { return; }
|
|
298
|
+
|
|
299
|
+
await
|
|
300
|
+
this.AddContentObjectGroupPermission({
|
|
301
|
+
objectId,
|
|
302
|
+
groupAddress: group,
|
|
303
|
+
permission: "manage"
|
|
304
|
+
});
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const metadata = {
|
|
309
|
+
public: {
|
|
310
|
+
name,
|
|
311
|
+
description,
|
|
312
|
+
asset_metadata: {
|
|
313
|
+
display_title: displayTitle || name,
|
|
314
|
+
title: name || displayTitle || defaultName,
|
|
315
|
+
title_type: "live_stream",
|
|
316
|
+
video_type: "live",
|
|
317
|
+
slug: slugify(name)
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
"live_recording_config": liveRecordingConfig
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const oldProfile = await this.ContentObjectMetadata({
|
|
324
|
+
libraryId,
|
|
325
|
+
objectId,
|
|
326
|
+
metadataSubtree: "live_recording_config/name"
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if(liveRecordingConfig?.name && liveRecordingConfig.name !== oldProfile) {
|
|
330
|
+
metadata.public.asset_metadata.profile_last_updated = new Date().toISOString();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await this.MergeMetadata({
|
|
334
|
+
libraryId,
|
|
335
|
+
objectId,
|
|
336
|
+
writeToken,
|
|
337
|
+
metadata
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await this.CreateLinks({
|
|
342
|
+
libraryId,
|
|
343
|
+
objectId,
|
|
344
|
+
writeToken,
|
|
345
|
+
links: [{
|
|
346
|
+
type: "rep",
|
|
347
|
+
path: "public/asset_metadata/sources/default",
|
|
348
|
+
target: "playout/default/options.json"
|
|
349
|
+
}]
|
|
350
|
+
});
|
|
351
|
+
} catch(error) {
|
|
352
|
+
console.log("Failed to create links", error);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await this.SetPermission({
|
|
356
|
+
objectId,
|
|
357
|
+
permission: permission,
|
|
358
|
+
writeToken
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
let returnResponse = {
|
|
362
|
+
objectId,
|
|
363
|
+
libraryId,
|
|
364
|
+
writeToken
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// If stream info is provided, continue to configure
|
|
368
|
+
if(liveRecordingConfig?.input_stream_info) {
|
|
369
|
+
await this.StreamConfig({
|
|
370
|
+
name: objectId,
|
|
371
|
+
liveRecordingConfig,
|
|
372
|
+
inputStreamInfo: liveRecordingConfig.input_stream_info,
|
|
373
|
+
writeToken,
|
|
374
|
+
finalize: false
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if(initializeDrm) {
|
|
379
|
+
const formats = liveRecordingConfig?.playout_config?.playout_formats;
|
|
380
|
+
|
|
381
|
+
await this.StreamInitialize({
|
|
382
|
+
name: objectId,
|
|
383
|
+
drm: (formats || []).some(el => !el.includes("clear")) ? true : false,
|
|
384
|
+
format: formats ? formats?.join(",") : "",
|
|
385
|
+
writeToken,
|
|
386
|
+
finalize: false
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if(finalize) {
|
|
391
|
+
let finalizeResponse = await this.FinalizeContentObject({
|
|
392
|
+
libraryId,
|
|
393
|
+
objectId,
|
|
394
|
+
writeToken,
|
|
395
|
+
commitMessage: existingObject ? "Update live stream" : "Create live stream"
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
returnResponse = {...returnResponse, ...finalizeResponse};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if(finalize && liveRecordingConfig?.name) {
|
|
402
|
+
const slug = slugify(liveRecordingConfig?.name);
|
|
403
|
+
await this.StreamAssignProfile({profileSlug: slug, streamObjectId: objectId});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if(options.linkToSite) {
|
|
407
|
+
await this.StreamLinkToSite({
|
|
408
|
+
objectId
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return returnResponse;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Load live stream data from site object
|
|
417
|
+
*
|
|
418
|
+
* @methodGroup Live Stream
|
|
419
|
+
* @namedParams
|
|
420
|
+
* @param {boolean=} resolveIncludeSource - If specified, resolved links will include the hash of the link at the root of the metadata
|
|
421
|
+
* @param {boolean=} resolveLinks - If specified, links in the metadata will be resolved
|
|
422
|
+
* @param {boolean=} resolveIgnoreErrors - If specified, link errors within the requested metadata will not cause the entire response to result in an error
|
|
423
|
+
*
|
|
424
|
+
* @return {Promise<Object>}
|
|
425
|
+
*/
|
|
426
|
+
exports.StreamSiteSettings = async function({
|
|
427
|
+
resolveLinks=true,
|
|
428
|
+
resolveIncludeSource=true,
|
|
429
|
+
resolveIgnoreErrors=true
|
|
430
|
+
}={}) {
|
|
431
|
+
const tenantId = await this.userProfileClient.TenantContractId();
|
|
432
|
+
|
|
433
|
+
if(!tenantId) {
|
|
434
|
+
throw new Error("Tenant ID not found. Ensure the user profile has a tenant contract configured.");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const tenantLibraryId = tenantId.replace("iten", "ilib");
|
|
438
|
+
const tenantObjectId = tenantId.replace("iten", "iq__");
|
|
439
|
+
|
|
440
|
+
const [siteObjectId, contentTypes] = await Promise.all([
|
|
441
|
+
this.ContentObjectMetadata({
|
|
442
|
+
libraryId: tenantLibraryId,
|
|
443
|
+
objectId: tenantObjectId,
|
|
444
|
+
metadataSubtree: "public/sites/live_streams",
|
|
445
|
+
}),
|
|
446
|
+
this.ContentObjectMetadata({
|
|
447
|
+
libraryId: tenantLibraryId,
|
|
448
|
+
objectId: tenantObjectId,
|
|
449
|
+
metadataSubtree: "public/content_types",
|
|
450
|
+
select: ["live_stream", "title"]
|
|
451
|
+
})
|
|
452
|
+
]);
|
|
453
|
+
|
|
454
|
+
const siteLibraryId = await this.ContentObjectLibraryId({objectId: siteObjectId});
|
|
455
|
+
|
|
456
|
+
const streamMetadata = await this.ContentObjectMetadata({
|
|
457
|
+
libraryId: siteLibraryId,
|
|
458
|
+
objectId: siteObjectId,
|
|
459
|
+
metadataSubtree: "public/asset_metadata/live_streams",
|
|
460
|
+
resolveIncludeSource,
|
|
461
|
+
resolveLinks,
|
|
462
|
+
resolveIgnoreErrors
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
streamMetadata,
|
|
467
|
+
siteObjectId,
|
|
468
|
+
siteLibraryId,
|
|
469
|
+
contentTypes
|
|
470
|
+
};
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Link a live stream object to a site by adding it to the site's live_streams metadata.
|
|
475
|
+
* Creates a fabric link to the stream object with proper ordering.
|
|
476
|
+
*
|
|
477
|
+
* @methodGroup Live Stream
|
|
478
|
+
* @namedParams
|
|
479
|
+
* @param {string} objectId - Object ID of the live stream to link to the site
|
|
480
|
+
*
|
|
481
|
+
* @return {Promise<void>}
|
|
482
|
+
*/
|
|
483
|
+
exports.StreamLinkToSite = async function({
|
|
484
|
+
objectId
|
|
485
|
+
}) {
|
|
486
|
+
try {
|
|
487
|
+
ValidateObject(objectId);
|
|
488
|
+
|
|
489
|
+
const {streamMetadata, siteObjectId, siteLibraryId} = await this.StreamSiteSettings({resolveLinks: false, resolveIncludeSource: false, resolveIgnoreErrors: false});
|
|
490
|
+
|
|
491
|
+
const alreadyLinked = Object.values(streamMetadata || {}).some(entry => {
|
|
492
|
+
const source = entry["."]?.source;
|
|
493
|
+
return source && this.utils.DecodeVersionHash(source).objectId === objectId;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if(alreadyLinked) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const objectName = await this.ContentObjectMetadata({
|
|
501
|
+
libraryId: await this.ContentObjectLibraryId({objectId}),
|
|
502
|
+
objectId,
|
|
503
|
+
metadataSubtree: "public/name"
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const streamKey = slugify(objectName);
|
|
507
|
+
|
|
508
|
+
const streamData = {
|
|
509
|
+
".": {
|
|
510
|
+
container: await this.LatestVersionHash({objectId: siteObjectId}),
|
|
511
|
+
auto_update: {
|
|
512
|
+
tag: "latest"
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
"/": `/qfab/${await this.LatestVersionHash({objectId})}/meta/public/asset_metadata`,
|
|
516
|
+
order: Object.keys(streamMetadata).length + 1
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const {writeToken} = await this.EditContentObject({
|
|
520
|
+
libraryId: siteLibraryId,
|
|
521
|
+
objectId: siteObjectId
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
streamMetadata[streamKey] = streamData;
|
|
525
|
+
|
|
526
|
+
await this.ReplaceMetadata({
|
|
527
|
+
libraryId: siteLibraryId,
|
|
528
|
+
objectId: siteObjectId,
|
|
529
|
+
writeToken,
|
|
530
|
+
metadataSubtree: "public/asset_metadata/live_streams",
|
|
531
|
+
metadata: streamMetadata
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await this.FinalizeContentObject({
|
|
535
|
+
libraryId: siteLibraryId,
|
|
536
|
+
objectId: siteObjectId,
|
|
537
|
+
writeToken,
|
|
538
|
+
commitMessage: "Add live stream",
|
|
539
|
+
awaitCommitConfirmation: true
|
|
540
|
+
});
|
|
541
|
+
} catch(error) {
|
|
542
|
+
// eslint-disable-next-line no-console
|
|
543
|
+
console.error("Failed to link stream object to site", JSON.stringify(error, null, 2));
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Unlink a live stream object from a site by removing it from the site's live_streams metadata.
|
|
549
|
+
*
|
|
550
|
+
* @methodGroup Live Stream
|
|
551
|
+
* @namedParams
|
|
552
|
+
* @param {string=} objectId - Object ID of the live stream to link to the site
|
|
553
|
+
* @param {string=} slug - Slug of the object
|
|
554
|
+
* @param {string=} siteObjectId - Object ID of the site (defaults to rootStore.dataStore.siteId)
|
|
555
|
+
* @param {string=} siteLibraryId - Library ID of the site (defaults to rootStore.dataStore.siteLibraryId)
|
|
556
|
+
* @param {string=} writeToken - Write token for the draft object. If not provided, a new edit will be opened and finalized.
|
|
557
|
+
* @param {boolean=} finalize - If enabled, the site object will be finalized after the removal (default: true)
|
|
558
|
+
* @return {Promise<void>}
|
|
559
|
+
*/
|
|
560
|
+
exports.StreamRemoveLinkToSite = async function({objectId, slug, writeToken, finalize=true}) {
|
|
561
|
+
try {
|
|
562
|
+
if(!objectId && !slug) {
|
|
563
|
+
throw new Error("Either objectId or slug must be provided.");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const {streamMetadata, siteObjectId, siteLibraryId} = await this.StreamSiteSettings({resolveIncludeSource: false, resolveLinks: false});
|
|
567
|
+
|
|
568
|
+
if(objectId) {
|
|
569
|
+
Object.keys(streamMetadata || {}).forEach(streamSlug => {
|
|
570
|
+
let source = streamMetadata[streamSlug]["."]?.source;
|
|
571
|
+
|
|
572
|
+
// If object has been deleted, resolving the link will not return a source, so check link hq__
|
|
573
|
+
if(!source) {
|
|
574
|
+
const match = streamMetadata[streamSlug]?.["/"].match(/(hq__[^/]+)/);
|
|
575
|
+
source = match ? match[1] : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const id = this.utils.DecodeVersionHash(source).objectId;
|
|
579
|
+
|
|
580
|
+
if(id === objectId) {
|
|
581
|
+
slug = streamSlug;
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if(slug) {
|
|
587
|
+
delete streamMetadata[slug];
|
|
588
|
+
|
|
589
|
+
if(!writeToken) {
|
|
590
|
+
({writeToken} = await this.EditContentObject({
|
|
591
|
+
libraryId: siteLibraryId,
|
|
592
|
+
objectId: siteObjectId
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
await this.DeleteMetadata({
|
|
597
|
+
libraryId: siteLibraryId,
|
|
598
|
+
objectId: siteObjectId,
|
|
599
|
+
writeToken,
|
|
600
|
+
metadataSubtree: `public/asset_metadata/live_streams/${slug}`
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
if(finalize) {
|
|
604
|
+
await this.FinalizeContentObject({
|
|
605
|
+
libraryId: siteLibraryId,
|
|
606
|
+
objectId: siteObjectId,
|
|
607
|
+
writeToken,
|
|
608
|
+
commitMessage: "Remove live stream",
|
|
609
|
+
awaitCommitConfirmation: true
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
throw new Error(`Provided objectId ${objectId} not found in site live_streams`);
|
|
614
|
+
}
|
|
615
|
+
} catch(error) {
|
|
616
|
+
// eslint-disable-next-line no-console
|
|
617
|
+
console.error("Failed to remove stream object link from site", error);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
64
621
|
/**
|
|
65
622
|
* Set the offering for the live stream
|
|
66
623
|
*
|
|
@@ -358,25 +915,27 @@ const StreamGenerateOffering = async({
|
|
|
358
915
|
*
|
|
359
916
|
* @return {Promise<Object>} - The status response for the object, as well as logs, warnings and errors from the master initialization
|
|
360
917
|
*/
|
|
361
|
-
exports.StreamStatus = async function({name, showParams=false}) {
|
|
918
|
+
exports.StreamStatus = async function({name, showParams=false, writeToken}) {
|
|
362
919
|
let objectId = name;
|
|
363
920
|
let status = {name: name};
|
|
364
921
|
|
|
365
922
|
try {
|
|
366
923
|
let libraryId = await this.ContentObjectLibraryId({objectId});
|
|
367
|
-
status.
|
|
368
|
-
status.
|
|
924
|
+
status.libraryId = libraryId;
|
|
925
|
+
status.objectId = objectId;
|
|
369
926
|
|
|
370
927
|
let mainMeta = await this.ContentObjectMetadata({
|
|
371
928
|
libraryId,
|
|
372
929
|
objectId,
|
|
930
|
+
writeToken,
|
|
373
931
|
select: [
|
|
374
932
|
"live_recording_config",
|
|
375
933
|
"live_recording"
|
|
376
934
|
]
|
|
377
935
|
});
|
|
378
936
|
|
|
379
|
-
status.
|
|
937
|
+
status.ingressNodeApi = mainMeta.live_recording_config?.ingress_node_api;
|
|
938
|
+
status.url = mainMeta.live_recording_config?.url;
|
|
380
939
|
|
|
381
940
|
if(mainMeta.live_recording_config == undefined || mainMeta.live_recording_config.url == undefined) {
|
|
382
941
|
status.state = "unconfigured";
|
|
@@ -402,8 +961,8 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
402
961
|
fabURI = "https://" + fabURI;
|
|
403
962
|
}
|
|
404
963
|
|
|
405
|
-
status.
|
|
406
|
-
|
|
964
|
+
status.fabricApi = fabURI;
|
|
965
|
+
|
|
407
966
|
|
|
408
967
|
let edgeWriteToken = mainMeta.live_recording.fabric_config.edge_write_token;
|
|
409
968
|
if(!edgeWriteToken) {
|
|
@@ -413,8 +972,8 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
413
972
|
|
|
414
973
|
this.RecordWriteToken({writeToken: edgeWriteToken, fabricNodeUrl: fabURI});
|
|
415
974
|
|
|
416
|
-
status.
|
|
417
|
-
status.
|
|
975
|
+
status.edgeWriteToken = edgeWriteToken;
|
|
976
|
+
status.streamId = edgeWriteToken; // By convention the stream ID is its write token
|
|
418
977
|
let edgeMeta;
|
|
419
978
|
try {
|
|
420
979
|
edgeMeta = await this.CallBitcodeMethod({
|
|
@@ -435,7 +994,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
435
994
|
return status;
|
|
436
995
|
}
|
|
437
996
|
|
|
438
|
-
status.
|
|
997
|
+
status.edgeMetaSize = JSON.stringify(edgeMeta || "").length;
|
|
439
998
|
|
|
440
999
|
// If a stream has never been started return state 'inactive'
|
|
441
1000
|
if(edgeMeta.live_recording === undefined ||
|
|
@@ -447,7 +1006,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
447
1006
|
}
|
|
448
1007
|
|
|
449
1008
|
let recordings = edgeMeta.live_recording.recordings;
|
|
450
|
-
status.
|
|
1009
|
+
status.recordingPeriodSequence = recordings.recording_sequence;
|
|
451
1010
|
|
|
452
1011
|
let sequence = recordings.recording_sequence;
|
|
453
1012
|
let period = recordings.live_offering[sequence - 1];
|
|
@@ -464,19 +1023,19 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
464
1023
|
sinceLastFinalize = Math.floor(new Date().getTime() / 1000) - videoLastFinalizationTimeEpochSec;
|
|
465
1024
|
}
|
|
466
1025
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1026
|
+
const recordingPeriod = {
|
|
1027
|
+
activationTimeEpochSec: period.recording_start_time_epoch_sec,
|
|
1028
|
+
startTimeEpochSec: period.start_time_epoch_sec,
|
|
1029
|
+
startTimeText: new Date(period.start_time_epoch_sec * 1000).toLocaleString(),
|
|
1030
|
+
endTimeEpochSec: period.end_time_epoch_sec,
|
|
1031
|
+
endTimeText: period.end_time_epoch_sec === 0 ? null : new Date(period.end_time_epoch_sec * 1000).toLocaleString(),
|
|
1032
|
+
videoParts: videoFinalizedParts,
|
|
1033
|
+
videoLastPartFinalizedEpochSec: videoLastFinalizationTimeEpochSec,
|
|
1034
|
+
videoSinceLastFinalizeSec: sinceLastFinalize
|
|
476
1035
|
};
|
|
477
|
-
status.
|
|
1036
|
+
status.recordingPeriod = recordingPeriod;
|
|
478
1037
|
|
|
479
|
-
status.
|
|
1038
|
+
status.lroStatusUrl = await this.FabricUrl({
|
|
480
1039
|
libraryId: libraryId,
|
|
481
1040
|
objectId: objectId,
|
|
482
1041
|
writeToken: edgeWriteToken,
|
|
@@ -488,30 +1047,31 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
488
1047
|
(edgeMeta.live_recording.playout_config.interleaves[sequence] != undefined)) {
|
|
489
1048
|
let insertions = edgeMeta.live_recording.playout_config.interleaves[sequence];
|
|
490
1049
|
for(let i = 0; i < insertions.length; i ++) {
|
|
491
|
-
let insertionTimeSinceEpoch =
|
|
1050
|
+
let insertionTimeSinceEpoch = recordingPeriod.startTimeEpochSec + insertions[i].insertion_time;
|
|
492
1051
|
status.insertions[i] = {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
1052
|
+
insertionTimeSinceStart: insertions[i].insertion_time,
|
|
1053
|
+
insertionTime: new Date(insertionTimeSinceEpoch * 1000).toISOString(),
|
|
1054
|
+
insertionTimeLocal: new Date(insertionTimeSinceEpoch * 1000).toLocaleString(),
|
|
496
1055
|
target: insertions[i].playout};
|
|
497
1056
|
}
|
|
498
1057
|
}
|
|
499
1058
|
|
|
500
1059
|
if(showParams) {
|
|
501
|
-
status.
|
|
1060
|
+
status.recordingParams = edgeMeta.live_recording.recording_config.recording_params;
|
|
502
1061
|
}
|
|
503
1062
|
|
|
504
1063
|
let state = "stopped";
|
|
505
1064
|
let lroStatus = "";
|
|
506
1065
|
try {
|
|
507
1066
|
lroStatus = await this.utils.ResponseToJson(
|
|
508
|
-
await HttpClient.Fetch(status.
|
|
1067
|
+
await HttpClient.Fetch(status.lroStatusUrl)
|
|
509
1068
|
);
|
|
510
1069
|
state = lroStatus.state;
|
|
511
1070
|
status.warnings = lroStatus.custom && lroStatus.custom.warnings;
|
|
512
1071
|
status.quality = lroStatus.custom && lroStatus.custom.quality;
|
|
1072
|
+
status.input_stats = lroStatus.custom && lroStatus.custom.input_stats;
|
|
513
1073
|
if(lroStatus.custom && lroStatus.custom.status) {
|
|
514
|
-
status.
|
|
1074
|
+
status.recordingStatus = lroStatus.custom.status;
|
|
515
1075
|
}
|
|
516
1076
|
} catch(error) {
|
|
517
1077
|
console.log("LRO Status (failed): ", error.response.statusCode);
|
|
@@ -529,10 +1089,16 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
529
1089
|
status.state = state;
|
|
530
1090
|
|
|
531
1091
|
if(state === "running") {
|
|
532
|
-
let
|
|
533
|
-
let playout_options
|
|
534
|
-
|
|
535
|
-
|
|
1092
|
+
let playoutUrls = {};
|
|
1093
|
+
let playout_options;
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
playout_options = await this.PlayoutOptions({
|
|
1097
|
+
objectId
|
|
1098
|
+
});
|
|
1099
|
+
} catch(error) {
|
|
1100
|
+
console.log("Failed to generate playout options based on 'default' offering:", error);
|
|
1101
|
+
}
|
|
536
1102
|
|
|
537
1103
|
let hls_clear_enabled = (
|
|
538
1104
|
playout_options &&
|
|
@@ -541,7 +1107,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
541
1107
|
playout_options.hls.playoutMethods.clear !== undefined
|
|
542
1108
|
);
|
|
543
1109
|
if(hls_clear_enabled) {
|
|
544
|
-
|
|
1110
|
+
playoutUrls.hlsClear = await this.FabricUrl({
|
|
545
1111
|
libraryId: libraryId,
|
|
546
1112
|
objectId: objectId,
|
|
547
1113
|
rep: "playout/default/hls-clear/playlist.m3u8",
|
|
@@ -555,7 +1121,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
555
1121
|
playout_options.hls.playoutMethods["aes-128"] !== undefined
|
|
556
1122
|
);
|
|
557
1123
|
if(hls_aes128_enabled) {
|
|
558
|
-
|
|
1124
|
+
playoutUrls.hlsAes128 = await this.FabricUrl({
|
|
559
1125
|
libraryId: libraryId,
|
|
560
1126
|
objectId: objectId,
|
|
561
1127
|
rep: "playout/default/hls-aes128/playlist.m3u8",
|
|
@@ -569,7 +1135,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
569
1135
|
playout_options.hls.playoutMethods["sample-aes"] !== undefined
|
|
570
1136
|
);
|
|
571
1137
|
if(hls_sample_aes_enabled) {
|
|
572
|
-
|
|
1138
|
+
playoutUrls.hlsSampleAes = await this.FabricUrl({
|
|
573
1139
|
libraryId: libraryId,
|
|
574
1140
|
objectId: objectId,
|
|
575
1141
|
rep: "playout/default/hls-sample-aes/playlist.m3u8",
|
|
@@ -589,10 +1155,9 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
589
1155
|
if(networkInfo.name.includes("demo")) {
|
|
590
1156
|
embed_net = "demo";
|
|
591
1157
|
}
|
|
592
|
-
|
|
593
|
-
playout_urls.embed_url = embed_url;
|
|
1158
|
+
playoutUrls.embedUrl = `https://embed.v3.contentfabric.io/?net=${embed_net}&p&ct=h&oid=${objectId}&mt=lv&ath=${token}`;
|
|
594
1159
|
|
|
595
|
-
status.
|
|
1160
|
+
status.playoutUrls = playoutUrls;
|
|
596
1161
|
}
|
|
597
1162
|
} catch(error) {
|
|
598
1163
|
console.error(error);
|
|
@@ -612,7 +1177,7 @@ exports.StreamStatus = async function({name, showParams=false}) {
|
|
|
612
1177
|
* @return {Promise<Object>} - The status response for the object
|
|
613
1178
|
*
|
|
614
1179
|
*/
|
|
615
|
-
exports.
|
|
1180
|
+
exports.StreamStartRecording = async function({name, start=false}) {
|
|
616
1181
|
let status = await this.StreamStatus({name});
|
|
617
1182
|
if(status.state != "uninitialized" && status.state !== "inactive" && status.state !== "terminated" && status.state !== "stopped") {
|
|
618
1183
|
return {
|
|
@@ -621,7 +1186,7 @@ exports.StreamCreate = async function({name, start=false}) {
|
|
|
621
1186
|
};
|
|
622
1187
|
}
|
|
623
1188
|
|
|
624
|
-
let objectId = status.
|
|
1189
|
+
let objectId = status.objectId;
|
|
625
1190
|
console.log("START: ", name, "start", start);
|
|
626
1191
|
|
|
627
1192
|
let libraryId = await this.ContentObjectLibraryId({objectId: objectId});
|
|
@@ -690,12 +1255,12 @@ exports.StreamCreate = async function({name, start=false}) {
|
|
|
690
1255
|
this.Log("Finalized object: ", objectHash);
|
|
691
1256
|
|
|
692
1257
|
status = {
|
|
693
|
-
|
|
1258
|
+
objectId: objectId,
|
|
694
1259
|
hash: objectHash,
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1260
|
+
libraryId: libraryId,
|
|
1261
|
+
streamId: edgeToken,
|
|
1262
|
+
edgeWriteToken: edgeToken,
|
|
1263
|
+
fabricApi: fabURI,
|
|
699
1264
|
state: "stopped"
|
|
700
1265
|
};
|
|
701
1266
|
|
|
@@ -735,9 +1300,9 @@ exports.StreamStartOrStopOrReset = async function({name, op}) {
|
|
|
735
1300
|
if(status.state == "running" || status.state == "starting" || status.state == "stalled") {
|
|
736
1301
|
try {
|
|
737
1302
|
await this.CallBitcodeMethod({
|
|
738
|
-
libraryId: status.
|
|
739
|
-
objectId: status.
|
|
740
|
-
writeToken: status.
|
|
1303
|
+
libraryId: status.libraryId,
|
|
1304
|
+
objectId: status.objectId,
|
|
1305
|
+
writeToken: status.edgeWriteToken,
|
|
741
1306
|
method: "/live/stop/" + status.tlro,
|
|
742
1307
|
constant: false
|
|
743
1308
|
});
|
|
@@ -766,13 +1331,13 @@ exports.StreamStartOrStopOrReset = async function({name, op}) {
|
|
|
766
1331
|
return status;
|
|
767
1332
|
}
|
|
768
1333
|
|
|
769
|
-
console.log("STARTING", "
|
|
1334
|
+
console.log("STARTING", "edgeWriteToken", status.edgeWriteToken);
|
|
770
1335
|
|
|
771
1336
|
try {
|
|
772
1337
|
await this.CallBitcodeMethod({
|
|
773
|
-
libraryId: status.
|
|
774
|
-
objectId: status.
|
|
775
|
-
writeToken: status.
|
|
1338
|
+
libraryId: status.libraryId,
|
|
1339
|
+
objectId: status.objectId,
|
|
1340
|
+
writeToken: status.edgeWriteToken,
|
|
776
1341
|
method: "/live/start",
|
|
777
1342
|
constant: false
|
|
778
1343
|
});
|
|
@@ -809,7 +1374,7 @@ exports.StreamStartOrStopOrReset = async function({name, op}) {
|
|
|
809
1374
|
*
|
|
810
1375
|
* @return {Promise<Object>} - The finalize response for the stream object
|
|
811
1376
|
*/
|
|
812
|
-
exports.
|
|
1377
|
+
exports.StreamStopRecording = async function({name}) {
|
|
813
1378
|
try {
|
|
814
1379
|
this.Log(`Terminating stream session for: ${name}`);
|
|
815
1380
|
let objectId = name;
|
|
@@ -845,13 +1410,16 @@ exports.StreamStopSession = async function({name}) {
|
|
|
845
1410
|
writeToken: metaEdgeWriteToken
|
|
846
1411
|
});
|
|
847
1412
|
|
|
848
|
-
|
|
1413
|
+
let status = await this.StreamStatus({name});
|
|
849
1414
|
|
|
850
1415
|
if(status.state !== "stopped") {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1416
|
+
status = await this.StreamStartOrStopOrReset({name, op: start});
|
|
1417
|
+
if(status.state !== "stopped") {
|
|
1418
|
+
return {
|
|
1419
|
+
status,
|
|
1420
|
+
error: "The stream is not stopped"
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
855
1423
|
}
|
|
856
1424
|
|
|
857
1425
|
await this.DeleteWriteToken({
|
|
@@ -929,45 +1497,49 @@ exports.StreamInitialize = async function({
|
|
|
929
1497
|
writeToken,
|
|
930
1498
|
finalize=true
|
|
931
1499
|
}) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1500
|
+
try {
|
|
1501
|
+
let typeAbrMaster;
|
|
1502
|
+
let typeLiveStream;
|
|
1503
|
+
|
|
1504
|
+
// Fetch Title and Live Stream content types from tenant meta
|
|
1505
|
+
const tenantContractId = await this.userProfileClient.TenantContractId();
|
|
1506
|
+
const {live_stream, title} = await this.ContentObjectMetadata({
|
|
1507
|
+
libraryId: tenantContractId.replace("iten", "ilib"),
|
|
1508
|
+
objectId: tenantContractId.replace("iten", "iq__"),
|
|
1509
|
+
metadataSubtree: "public/content_types",
|
|
1510
|
+
select: [
|
|
1511
|
+
"live_stream",
|
|
1512
|
+
"title"
|
|
1513
|
+
]
|
|
1514
|
+
});
|
|
946
1515
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1516
|
+
if(live_stream) {
|
|
1517
|
+
typeLiveStream = live_stream;
|
|
1518
|
+
}
|
|
950
1519
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1520
|
+
if(title) {
|
|
1521
|
+
typeAbrMaster = title;
|
|
1522
|
+
}
|
|
954
1523
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1524
|
+
if(typeAbrMaster === undefined || typeLiveStream === undefined) {
|
|
1525
|
+
console.log("ERROR - unable to find content types", "ABR Master", typeAbrMaster, "Live Stream", typeLiveStream);
|
|
1526
|
+
return {};
|
|
1527
|
+
}
|
|
959
1528
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1529
|
+
res = await this.StreamSetOfferingAndDRM({
|
|
1530
|
+
name,
|
|
1531
|
+
typeAbrMaster,
|
|
1532
|
+
typeLiveStream,
|
|
1533
|
+
drm,
|
|
1534
|
+
format,
|
|
1535
|
+
writeToken,
|
|
1536
|
+
finalize
|
|
1537
|
+
});
|
|
969
1538
|
|
|
970
|
-
|
|
1539
|
+
return res;
|
|
1540
|
+
} catch(error) {
|
|
1541
|
+
console.error("Unable to intitialize stream", error);
|
|
1542
|
+
}
|
|
971
1543
|
};
|
|
972
1544
|
|
|
973
1545
|
/**
|
|
@@ -996,14 +1568,16 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
996
1568
|
finalize=true
|
|
997
1569
|
}) {
|
|
998
1570
|
let status = await this.StreamStatus({name});
|
|
999
|
-
|
|
1571
|
+
console.log('StreamSetOfferingAndDrm', status)
|
|
1572
|
+
const validStates = ["uninitialized", "inactive", "stopped", "unconfigured", "initialized"];
|
|
1573
|
+
if(!validStates.includes(status.state)) {
|
|
1000
1574
|
return {
|
|
1001
1575
|
state: status.state,
|
|
1002
1576
|
error: "stream still active - must terminate first"
|
|
1003
1577
|
};
|
|
1004
1578
|
}
|
|
1005
1579
|
|
|
1006
|
-
let objectId = status.
|
|
1580
|
+
let objectId = status.objectId;
|
|
1007
1581
|
|
|
1008
1582
|
console.log("INIT: ", name, objectId);
|
|
1009
1583
|
|
|
@@ -1030,8 +1604,8 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1030
1604
|
drm = true; // Override DRM parameter
|
|
1031
1605
|
playoutFormats = {};
|
|
1032
1606
|
let formats = format.split(",");
|
|
1033
|
-
for(let
|
|
1034
|
-
if(
|
|
1607
|
+
for(let option of formats) {
|
|
1608
|
+
if(option === "hls-clear") {
|
|
1035
1609
|
abrProfile.drm_optional = true;
|
|
1036
1610
|
playoutFormats["hls-clear"] = {
|
|
1037
1611
|
"drm": null,
|
|
@@ -1041,7 +1615,7 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1041
1615
|
};
|
|
1042
1616
|
continue;
|
|
1043
1617
|
}
|
|
1044
|
-
if(
|
|
1618
|
+
if(option === "dash-clear") {
|
|
1045
1619
|
abrProfile.drm_optional = true;
|
|
1046
1620
|
playoutFormats["dash-clear"] = {
|
|
1047
1621
|
"drm": null,
|
|
@@ -1053,7 +1627,7 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1053
1627
|
continue;
|
|
1054
1628
|
}
|
|
1055
1629
|
|
|
1056
|
-
playoutFormats[
|
|
1630
|
+
playoutFormats[option] = abrProfile.playout_formats[option];
|
|
1057
1631
|
}
|
|
1058
1632
|
} else if(!drm) {
|
|
1059
1633
|
abrProfile.drm_optional = true;
|
|
@@ -1087,7 +1661,25 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1087
1661
|
writeToken
|
|
1088
1662
|
});
|
|
1089
1663
|
|
|
1090
|
-
|
|
1664
|
+
const nodeId = mainMeta?.live_recording_config?.ingress_node_id;
|
|
1665
|
+
let streamData;
|
|
1666
|
+
if(nodeId) {
|
|
1667
|
+
streamData = {client: this, nodeId, nodeApi: mainMeta?.live_recording_config?.url};
|
|
1668
|
+
} else {
|
|
1669
|
+
const url = mainMeta?.live_recording?.fabric_config?.ingress_node_api || mainMeta?.live_recording_config?.url;
|
|
1670
|
+
streamData = {client: this, url};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
let node, endpoint;
|
|
1674
|
+
try {
|
|
1675
|
+
({node, endpoint} = await GetNodeFromStreamData(streamData));
|
|
1676
|
+
status.node = node;
|
|
1677
|
+
} catch(error) {
|
|
1678
|
+
status.error = error.message;
|
|
1679
|
+
return status;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
let fabURI = endpoint;
|
|
1091
1683
|
// Support both hostname and URL ingress_node_api
|
|
1092
1684
|
if(!fabURI.startsWith("http")) {
|
|
1093
1685
|
// Assume https
|
|
@@ -1096,7 +1688,7 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1096
1688
|
|
|
1097
1689
|
this.SetNodes({fabricURIs: [fabURI]});
|
|
1098
1690
|
|
|
1099
|
-
let streamUrl = mainMeta
|
|
1691
|
+
let streamUrl = mainMeta?.live_recording?.recording_config?.recording_params?.origin_url || mainMeta?.live_recording_config?.url;
|
|
1100
1692
|
|
|
1101
1693
|
await StreamGenerateOffering({
|
|
1102
1694
|
client: this,
|
|
@@ -1127,7 +1719,7 @@ exports.StreamSetOfferingAndDRM = async function({
|
|
|
1127
1719
|
|
|
1128
1720
|
return {
|
|
1129
1721
|
name,
|
|
1130
|
-
|
|
1722
|
+
objectId: objectId,
|
|
1131
1723
|
state: "initialized"
|
|
1132
1724
|
};
|
|
1133
1725
|
} catch(error) {
|
|
@@ -1335,288 +1927,943 @@ exports.StreamInsertion = async function({name, insertionTime, sinceStart=false,
|
|
|
1335
1927
|
};
|
|
1336
1928
|
|
|
1337
1929
|
/**
|
|
1338
|
-
*
|
|
1930
|
+
* Get all available live recording config profiles from the live stream site configuration.
|
|
1931
|
+
* Ladder profiles define settings for configuring live streams including recording config, playout config, and recording params.
|
|
1339
1932
|
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1933
|
+
* @methodGroup Live Stream
|
|
1934
|
+
*
|
|
1935
|
+
* @returns {Promise<Object>} - Object containing all live recording config profiles
|
|
1936
|
+
*/
|
|
1937
|
+
exports.StreamConfigProfiles = async function({resolveLinks=false}={}) {
|
|
1938
|
+
const {siteObjectId, siteLibraryId} = await this.StreamSiteSettings();
|
|
1939
|
+
|
|
1940
|
+
const profiles = await this.ContentObjectMetadata({
|
|
1941
|
+
libraryId: siteLibraryId,
|
|
1942
|
+
objectId: siteObjectId,
|
|
1943
|
+
metadataSubtree: "public/asset_metadata/profiles",
|
|
1944
|
+
resolveLinks
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
if(!profiles || !resolveLinks) {
|
|
1948
|
+
return profiles;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// The fabric's resolve=true only follows metadata links, not file links.
|
|
1952
|
+
// Detect unresolved file links (e.g. { '/': './files/...' }) and fetch them individually.
|
|
1953
|
+
const resolved = {};
|
|
1954
|
+
await Promise.all(
|
|
1955
|
+
Object.entries(profiles).map(async ([name, profile]) => {
|
|
1956
|
+
if(profile && typeof profile["/"] === "string" && profile["/"].startsWith("./files/")) {
|
|
1957
|
+
try {
|
|
1958
|
+
resolved[name] = await this.LinkData({
|
|
1959
|
+
libraryId: siteLibraryId,
|
|
1960
|
+
objectId: siteObjectId,
|
|
1961
|
+
linkPath: `public/asset_metadata/profiles/${name}`,
|
|
1962
|
+
format: "json"
|
|
1963
|
+
});
|
|
1964
|
+
} catch(e) {
|
|
1965
|
+
resolved[name] = profile;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
})
|
|
1969
|
+
);
|
|
1970
|
+
|
|
1971
|
+
return resolved;
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Get a specific live recording config profile's specifications by name.
|
|
1358
1976
|
*
|
|
1359
1977
|
* @methodGroup Live Stream
|
|
1360
1978
|
* @namedParams
|
|
1361
|
-
* @param {string}
|
|
1362
|
-
* @param {
|
|
1363
|
-
* @param {Object=} probeMetadata - Metadata for the probe. If not specified, a new probe will be configured
|
|
1364
|
-
* @param {string=} writeToken - Write token of the draft
|
|
1365
|
-
* @param {boolean=} finalize - If enabled, target object will be finalized after configuring
|
|
1979
|
+
* @param {string} profileName - Name of the profile to retrieve
|
|
1980
|
+
* @param {string} profileSlug - Slug of the profile to retrieve
|
|
1366
1981
|
*
|
|
1367
|
-
|
|
1982
|
+
@returns {Promise<Object>} - The specifications for the requested profile
|
|
1368
1983
|
*
|
|
1369
1984
|
*/
|
|
1370
|
-
exports.StreamConfig = async function({
|
|
1371
|
-
name,
|
|
1372
|
-
customSettings={},
|
|
1373
|
-
customMetaValues={},
|
|
1374
|
-
probeMetadata,
|
|
1375
|
-
writeToken,
|
|
1376
|
-
finalize=true
|
|
1377
|
-
}) {
|
|
1378
|
-
let objectId = name;
|
|
1379
|
-
let status = {name};
|
|
1380
1985
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1986
|
+
exports.StreamConfigProfile = async function({profileName, profileSlug}) {
|
|
1987
|
+
console.log({profileName, profileSlug})
|
|
1988
|
+
if(!profileName && !profileSlug) {
|
|
1989
|
+
throw new Error("Either profileName or profileSlug must be provided.");
|
|
1990
|
+
}
|
|
1384
1991
|
|
|
1385
|
-
|
|
1992
|
+
if(!profileSlug) {
|
|
1993
|
+
profileSlug = slugify(profileName);
|
|
1994
|
+
}
|
|
1386
1995
|
|
|
1387
|
-
|
|
1388
|
-
libraryId: libraryId,
|
|
1389
|
-
objectId: objectId
|
|
1390
|
-
});
|
|
1996
|
+
const {siteObjectId, siteLibraryId} = await this.StreamSiteSettings();
|
|
1391
1997
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1998
|
+
const profileData = await this.ContentObjectMetadata({
|
|
1999
|
+
libraryId: siteLibraryId,
|
|
2000
|
+
objectId: siteObjectId,
|
|
2001
|
+
metadataSubtree: `public/asset_metadata/profiles/${profileSlug}`,
|
|
2002
|
+
resolveLinks: true
|
|
2003
|
+
});
|
|
1394
2004
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
.replace("udp://", "https://")
|
|
1398
|
-
.replace("rtmp://", "https://")
|
|
1399
|
-
.replace("srt://", "https://");
|
|
1400
|
-
const hostName = new URL(parsedName).hostname;
|
|
1401
|
-
const streamUrl = new URL(userConfig.url);
|
|
1402
|
-
|
|
1403
|
-
this.Log(`Retrieving nodes - matching: ${hostName}`);
|
|
1404
|
-
let nodes = await this.SpaceNodes({matchEndpoint: hostName});
|
|
1405
|
-
if(nodes.length < 1) {
|
|
1406
|
-
status.error = "No node matching stream URL " + streamUrl.href;
|
|
1407
|
-
return status;
|
|
2005
|
+
if(!profileData) {
|
|
2006
|
+
console.warn(`Live Recording Config profile ${profileName} not found.`);
|
|
1408
2007
|
}
|
|
1409
|
-
const node = {
|
|
1410
|
-
endpoints: nodes[0].services.fabric_api.urls,
|
|
1411
|
-
id: nodes[0].id
|
|
1412
|
-
};
|
|
1413
|
-
status.node = node;
|
|
1414
|
-
let endpoint = node.endpoints[0];
|
|
1415
|
-
|
|
1416
|
-
if(!probe) {
|
|
1417
|
-
this.SetNodes({fabricURIs: [endpoint]});
|
|
1418
2008
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
try {
|
|
1422
|
-
let probeUrl = await this.Rep({
|
|
1423
|
-
libraryId,
|
|
1424
|
-
objectId,
|
|
1425
|
-
rep: "probe"
|
|
1426
|
-
});
|
|
2009
|
+
return profileData;
|
|
2010
|
+
};
|
|
1427
2011
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
2012
|
+
/**
|
|
2013
|
+
* Save a live recording config profile to the live stream site object.
|
|
2014
|
+
* Uploads any provided profile files or profile metadata and creates a metadata link to the profile. One must be specified. The argument profileMetadata takes precendence if both are provided.
|
|
2015
|
+
*
|
|
2016
|
+
* @methodGroup Live Stream
|
|
2017
|
+
* @namedParams
|
|
2018
|
+
* @param {FileList|Array<File>=} files - Profile files to upload to the site object
|
|
2019
|
+
* @param {Object=} profileMetadata - Metadata for the profile
|
|
2020
|
+
*
|
|
2021
|
+
* @returns {Promise<void>}
|
|
2022
|
+
*/
|
|
2023
|
+
exports.StreamSaveConfigProfile = async function({
|
|
2024
|
+
files,
|
|
2025
|
+
profileMetadata,
|
|
2026
|
+
writeToken,
|
|
2027
|
+
finalize=true
|
|
2028
|
+
}) {
|
|
2029
|
+
if(!files && !profileMetadata) {
|
|
2030
|
+
throw new Error("Missing required field: Please specify files or profileMetadata.")
|
|
2031
|
+
}
|
|
1437
2032
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
throw "Stream probe time out - make sure the stream source is available";
|
|
1444
|
-
} else {
|
|
1445
|
-
throw error;
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
2033
|
+
const profiles = await this.StreamConfigProfiles({resolveLinks: true});
|
|
2034
|
+
const {
|
|
2035
|
+
siteObjectId: objectId,
|
|
2036
|
+
siteLibraryId: libraryId
|
|
2037
|
+
} = await this.StreamSiteSettings();
|
|
1448
2038
|
|
|
1449
|
-
|
|
2039
|
+
if(!writeToken) {
|
|
2040
|
+
({writeToken} = await this.EditContentObject({
|
|
2041
|
+
libraryId,
|
|
2042
|
+
objectId
|
|
2043
|
+
}));
|
|
1450
2044
|
}
|
|
1451
2045
|
|
|
1452
|
-
|
|
1453
|
-
|
|
2046
|
+
if(profileMetadata) {
|
|
2047
|
+
const defaultName = `Profile-${new Date().toISOString().slice(0, 10)}`;
|
|
2048
|
+
profileMetadata.last_updated = new Date().toISOString();
|
|
1454
2049
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
2050
|
+
const metaFileName = slugify(profileMetadata.name || defaultName);
|
|
2051
|
+
const blob = new Blob([JSON.stringify(profileMetadata, null, 2)], {type: "application/json"});
|
|
2052
|
+
const metaFile = new File([blob], `${metaFileName}.json`, {type: "application/json"});
|
|
2053
|
+
files = files ? [...Array.from(files), metaFile] : [metaFile];
|
|
2054
|
+
}
|
|
1458
2055
|
|
|
1459
|
-
|
|
1460
|
-
if(
|
|
1461
|
-
|
|
2056
|
+
let fileInfo;
|
|
2057
|
+
if(files) {
|
|
2058
|
+
fileInfo = await FileInfo({
|
|
2059
|
+
path: "live_stream_profiles",
|
|
2060
|
+
fileList: files
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
await this.UploadFiles({
|
|
1462
2064
|
libraryId,
|
|
1463
|
-
objectId
|
|
2065
|
+
objectId,
|
|
2066
|
+
writeToken,
|
|
2067
|
+
fileInfo
|
|
1464
2068
|
});
|
|
1465
|
-
writeToken = e.write_token;
|
|
1466
2069
|
}
|
|
1467
2070
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
2071
|
+
const links = fileInfo.map(file => {
|
|
2072
|
+
const fileName = path.parse(file.name).name;
|
|
2073
|
+
|
|
2074
|
+
return {
|
|
2075
|
+
type: "file",
|
|
2076
|
+
path: `public/asset_metadata/profiles/${slugify(fileName)}`,
|
|
2077
|
+
target: file.path
|
|
2078
|
+
}
|
|
1474
2079
|
});
|
|
1475
2080
|
|
|
1476
|
-
await this.
|
|
2081
|
+
await this.CreateLinks({
|
|
1477
2082
|
libraryId,
|
|
1478
2083
|
objectId,
|
|
1479
2084
|
writeToken,
|
|
1480
|
-
|
|
1481
|
-
metadata: probe
|
|
2085
|
+
links
|
|
1482
2086
|
});
|
|
1483
2087
|
|
|
1484
2088
|
if(finalize) {
|
|
1485
|
-
|
|
2089
|
+
await this.FinalizeContentObject({
|
|
1486
2090
|
libraryId,
|
|
1487
2091
|
objectId,
|
|
1488
2092
|
writeToken,
|
|
1489
|
-
commitMessage: "
|
|
2093
|
+
commitMessage: "Add live recording config profile"
|
|
1490
2094
|
});
|
|
1491
2095
|
}
|
|
1492
|
-
|
|
1493
|
-
return status;
|
|
1494
2096
|
};
|
|
1495
2097
|
|
|
1496
2098
|
/**
|
|
1497
|
-
*
|
|
2099
|
+
* Assign a live recording config profile to a stream by adding the stream to the
|
|
2100
|
+
* profile's stream index on the site object. If the stream is already assigned, this is a no-op.
|
|
1498
2101
|
*
|
|
1499
2102
|
* @methodGroup Live Stream
|
|
1500
2103
|
* @namedParams
|
|
1501
|
-
* @param {string
|
|
2104
|
+
* @param {string} profileSlug - Slug of the profile to assign
|
|
2105
|
+
* @param {string} streamObjectId - Object ID of the stream to assign the profile to
|
|
2106
|
+
* @param {string=} writeToken - Write token for the site object. If not provided, a new edit will be opened and finalized.
|
|
2107
|
+
* @param {boolean=} finalize - If enabled, the site object will be finalized after the assignment (default: true)
|
|
1502
2108
|
*
|
|
1503
|
-
* @
|
|
2109
|
+
* @returns {Promise<void>}
|
|
1504
2110
|
*/
|
|
1505
|
-
exports.
|
|
2111
|
+
exports.StreamAssignProfile = async function({
|
|
2112
|
+
profileSlug,
|
|
2113
|
+
streamObjectId,
|
|
2114
|
+
writeToken,
|
|
2115
|
+
finalize=true
|
|
2116
|
+
}) {
|
|
1506
2117
|
try {
|
|
1507
|
-
const
|
|
1508
|
-
UNCONFIGURED: "unconfigured",
|
|
1509
|
-
UNINITIALIZED: "uninitialized",
|
|
1510
|
-
INACTIVE: "inactive",
|
|
1511
|
-
STOPPED: "stopped",
|
|
1512
|
-
STARTING: "starting",
|
|
1513
|
-
RUNNING: "running",
|
|
1514
|
-
STALLED: "stalled",
|
|
1515
|
-
};
|
|
1516
|
-
|
|
1517
|
-
if(!siteId) {
|
|
1518
|
-
const tenantContractId = await this.userProfileClient.TenantContractId();
|
|
1519
|
-
|
|
1520
|
-
if(!tenantContractId) {
|
|
1521
|
-
throw Error("No tenant contract ID configured");
|
|
1522
|
-
}
|
|
2118
|
+
const {siteObjectId: objectId, siteLibraryId: libraryId} = await this.StreamSiteSettings({resolveIncludeSource: false, resolveLinks: false});
|
|
1523
2119
|
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
objectId: tenantContractId.replace("iten", "iq__"),
|
|
1527
|
-
metadataSubtree: "public/sites/live_streams",
|
|
1528
|
-
});
|
|
2120
|
+
if(!objectId || !libraryId) {
|
|
2121
|
+
throw new Error("Site object must be configured first.")
|
|
1529
2122
|
}
|
|
1530
2123
|
|
|
1531
|
-
const
|
|
1532
|
-
libraryId
|
|
1533
|
-
objectId
|
|
1534
|
-
metadataSubtree:
|
|
1535
|
-
|
|
1536
|
-
resolveIgnoreErrors: true,
|
|
1537
|
-
resolveIncludeSource: true
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
const activeUrlMap = {};
|
|
1541
|
-
await this.utils.LimitedMap(
|
|
1542
|
-
10,
|
|
1543
|
-
Object.keys(streamMetadata || {}),
|
|
1544
|
-
async slug => {
|
|
1545
|
-
const stream = streamMetadata[slug];
|
|
1546
|
-
let versionHash;
|
|
2124
|
+
const profileStreams = await this.ContentObjectMetadata({
|
|
2125
|
+
libraryId,
|
|
2126
|
+
objectId,
|
|
2127
|
+
metadataSubtree: `public/asset_metadata/profile_streams/${profileSlug}`
|
|
2128
|
+
}) || [];
|
|
1547
2129
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
}
|
|
2130
|
+
if(!profileStreams.includes(streamObjectId)) {
|
|
2131
|
+
profileStreams.push(streamObjectId);
|
|
1551
2132
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
2133
|
+
if(!writeToken) {
|
|
2134
|
+
({writeToken} = await this.EditContentObject({
|
|
2135
|
+
libraryId,
|
|
2136
|
+
objectId
|
|
2137
|
+
}))
|
|
2138
|
+
}
|
|
1555
2139
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2140
|
+
await this.ReplaceMetadata({
|
|
2141
|
+
libraryId,
|
|
2142
|
+
objectId,
|
|
2143
|
+
writeToken,
|
|
2144
|
+
metadataSubtree: `public/asset_metadata/profile_streams/${profileSlug}`,
|
|
2145
|
+
metadata: profileStreams
|
|
2146
|
+
});
|
|
1559
2147
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2148
|
+
if(finalize) {
|
|
2149
|
+
await this.FinalizeContentObject({
|
|
2150
|
+
libraryId,
|
|
2151
|
+
objectId,
|
|
2152
|
+
writeToken,
|
|
2153
|
+
commitMessage: "Assign profile to stream"
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
} catch(error) {
|
|
2158
|
+
console.error("Unable to assign profile to stream", error);
|
|
2159
|
+
throw error;
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
1569
2162
|
|
|
1570
|
-
|
|
1571
|
-
|
|
2163
|
+
/**
|
|
2164
|
+
* Unassign a live recording config profile from a stream by removing the stream from the
|
|
2165
|
+
* profile's stream index on the site object.
|
|
2166
|
+
*
|
|
2167
|
+
* @methodGroup Live Stream
|
|
2168
|
+
* @namedParams
|
|
2169
|
+
* @param {string} profileSlug - Slug of the profile to unassign
|
|
2170
|
+
* @param {string} streamObjectId - Object ID of the stream to remove from the profile
|
|
2171
|
+
* @param {string=} writeToken - Write token for the site object. If not provided, a new edit will be opened and finalized.
|
|
2172
|
+
* @param {boolean=} finalize - If enabled, the site object will be finalized after the unassignment (default: true)
|
|
2173
|
+
*
|
|
2174
|
+
* @returns {Promise<void>}
|
|
2175
|
+
*/
|
|
2176
|
+
exports.StreamUnassignProfile = async function({
|
|
2177
|
+
profileSlug,
|
|
2178
|
+
streamObjectId,
|
|
2179
|
+
writeToken,
|
|
2180
|
+
finalize=true
|
|
2181
|
+
}) {
|
|
2182
|
+
try {
|
|
2183
|
+
const {siteObjectId: objectId, siteLibraryId: libraryId} = await this.StreamSiteSettings({resolveIncludeSource: false, resolveLinks: false});
|
|
1572
2184
|
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
);
|
|
2185
|
+
if(!objectId || !libraryId) {
|
|
2186
|
+
throw new Error("Site object must be configured first.")
|
|
2187
|
+
}
|
|
1579
2188
|
|
|
1580
|
-
|
|
2189
|
+
let profileStreams = await this.ContentObjectMetadata({
|
|
2190
|
+
libraryId,
|
|
2191
|
+
objectId,
|
|
2192
|
+
metadataSubtree: `public/asset_metadata/profile_streams/${profileSlug}`
|
|
2193
|
+
}) || [];
|
|
1581
2194
|
|
|
1582
|
-
|
|
1583
|
-
libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
|
|
1584
|
-
objectId: siteId,
|
|
1585
|
-
metadataSubtree: "/live_stream_urls",
|
|
1586
|
-
resolveLinks: true,
|
|
1587
|
-
resolveIgnoreErrors: true
|
|
1588
|
-
});
|
|
2195
|
+
profileStreams = profileStreams.filter(id => id !== streamObjectId);
|
|
1589
2196
|
|
|
1590
|
-
if(!
|
|
1591
|
-
|
|
2197
|
+
if(!writeToken) {
|
|
2198
|
+
({writeToken} = await this.EditContentObject({
|
|
2199
|
+
libraryId,
|
|
2200
|
+
objectId
|
|
2201
|
+
}));
|
|
1592
2202
|
}
|
|
1593
2203
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
});
|
|
2204
|
+
await this.ReplaceMetadata({
|
|
2205
|
+
libraryId,
|
|
2206
|
+
objectId,
|
|
2207
|
+
writeToken,
|
|
2208
|
+
metadataSubtree: `public/asset_metadata/profile_streams/${profileSlug}`,
|
|
2209
|
+
metadata: profileStreams
|
|
1601
2210
|
});
|
|
1602
2211
|
|
|
1603
|
-
|
|
2212
|
+
if(finalize) {
|
|
2213
|
+
await this.FinalizeContentObject({
|
|
2214
|
+
libraryId,
|
|
2215
|
+
objectId,
|
|
2216
|
+
writeToken,
|
|
2217
|
+
commitMessage: "Unassign profile to stream"
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
1604
2220
|
} catch(error) {
|
|
1605
|
-
console.error(error);
|
|
2221
|
+
console.error("Unable to unassign profile to stream", error);
|
|
2222
|
+
throw error;
|
|
1606
2223
|
}
|
|
1607
2224
|
};
|
|
1608
2225
|
|
|
1609
2226
|
/**
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
* -
|
|
1619
|
-
*
|
|
2227
|
+
* Apply a live recording config profile to a stream, writing the merged configuration
|
|
2228
|
+
* to the stream's live_recording_config and updating the profile's stream index on the site object.
|
|
2229
|
+
* Handles switching profiles by unassigning the previous profile from the index.
|
|
2230
|
+
* Use this for CLI workflows. For app workflows managing the config edit separately, use StreamAssignProfile directly.
|
|
2231
|
+
*
|
|
2232
|
+
* @methodGroup Live Stream
|
|
2233
|
+
* @namedParams
|
|
2234
|
+
* @param {string=} profileSlug - Slug of the profile to apply. Required if profile is not provided.
|
|
2235
|
+
* @param {Object=} profile - Profile object to apply. Required if profileSlug is not provided. If both are provided, profile is used and profileSlug is derived from profile.name.
|
|
2236
|
+
* @param {string} objectId - Object ID of the stream to apply the profile to
|
|
2237
|
+
* @param {string=} streamWriteToken - Write token for the stream object. If not provided, a new edit will be opened.
|
|
2238
|
+
* @param {string=} siteWriteToken - Write token for the site object. If not provided, a new edit will be opened.
|
|
2239
|
+
* @param {boolean=} finalize - If enabled, both the stream and site objects will be finalized (default: true)
|
|
2240
|
+
*
|
|
2241
|
+
* @returns {Promise<{config: Object}|{streamWriteToken: string, siteWriteToken: string}>} - The merged config if finalized, otherwise the open write tokens
|
|
2242
|
+
*/
|
|
2243
|
+
exports.StreamApplyProfile = async function({
|
|
2244
|
+
profileSlug,
|
|
2245
|
+
profile,
|
|
2246
|
+
objectId,
|
|
2247
|
+
streamWriteToken,
|
|
2248
|
+
siteWriteToken,
|
|
2249
|
+
finalize=true
|
|
2250
|
+
}) {
|
|
2251
|
+
if(!profile && !profileSlug) {
|
|
2252
|
+
throw new Error("Either profile or profileSlug must be provided.");
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if(!profile) {
|
|
2256
|
+
profile = await this.StreamConfigProfile({profileSlug});
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const libraryId = await this.ContentObjectLibraryId({objectId});
|
|
2260
|
+
|
|
2261
|
+
if(!streamWriteToken) {
|
|
2262
|
+
({writeToken: streamWriteToken} = await this.EditContentObject({
|
|
2263
|
+
libraryId,
|
|
2264
|
+
objectId
|
|
2265
|
+
}));
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const currentConfig = await this.ContentObjectMetadata({
|
|
2269
|
+
libraryId,
|
|
2270
|
+
objectId,
|
|
2271
|
+
writeToken: streamWriteToken,
|
|
2272
|
+
metadataSubtree: "live_recording_config"
|
|
2273
|
+
}) || {};
|
|
2274
|
+
|
|
2275
|
+
// Only preserve stream-identity fields from the current config; all technical settings come from the new profile
|
|
2276
|
+
const preservedConfig = R.pick(["url", "name"], currentConfig);
|
|
2277
|
+
const config = R.mergeDeepRight(preservedConfig, profile);
|
|
2278
|
+
|
|
2279
|
+
const currentProfileName = await this.ContentObjectMetadata({
|
|
2280
|
+
libraryId,
|
|
2281
|
+
objectId,
|
|
2282
|
+
writeToken: streamWriteToken,
|
|
2283
|
+
metadataSubtree: "live_recording_config/name"
|
|
2284
|
+
});
|
|
2285
|
+
const currentProfileSlug = slugify(currentProfileName);
|
|
2286
|
+
|
|
2287
|
+
await this.StreamUpdateConfig({
|
|
2288
|
+
libraryId,
|
|
2289
|
+
objectId,
|
|
2290
|
+
writeToken: streamWriteToken,
|
|
2291
|
+
finalize: false,
|
|
2292
|
+
liveRecordingConfig: config
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
// Clear live_recording and live_recording_overrides so stale settings from the previous profile don't persist
|
|
2296
|
+
await this.ReplaceMetadata({
|
|
2297
|
+
libraryId,
|
|
2298
|
+
objectId,
|
|
2299
|
+
writeToken: streamWriteToken,
|
|
2300
|
+
metadataSubtree: "live_recording",
|
|
2301
|
+
metadata: {}
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
await this.ReplaceMetadata({
|
|
2305
|
+
libraryId,
|
|
2306
|
+
objectId,
|
|
2307
|
+
writeToken: streamWriteToken,
|
|
2308
|
+
metadataSubtree: "live_recording_overrides",
|
|
2309
|
+
metadata: {}
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
// If input_stream_info is available, regenerate live_recording from the new profile now
|
|
2313
|
+
if(config.input_stream_info) {
|
|
2314
|
+
await this.StreamConfig({
|
|
2315
|
+
name: objectId,
|
|
2316
|
+
liveRecordingConfig: config,
|
|
2317
|
+
inputStreamInfo: config.input_stream_info,
|
|
2318
|
+
writeToken: streamWriteToken,
|
|
2319
|
+
finalize: false
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
if(!profileSlug) {
|
|
2324
|
+
profileSlug = slugify(profile.name);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
const {siteObjectId, siteLibraryId} = await this.StreamSiteSettings({resolveIncludeSource: false, resolveLinks: false});
|
|
2328
|
+
|
|
2329
|
+
if(!siteWriteToken) {
|
|
2330
|
+
({writeToken: siteWriteToken} = await this.EditContentObject({libraryId: siteLibraryId, objectId: siteObjectId}));
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
if(currentProfileSlug && currentProfileSlug !== profileSlug) {
|
|
2334
|
+
await this.StreamUnassignProfile({
|
|
2335
|
+
profileSlug: currentProfileSlug,
|
|
2336
|
+
streamObjectId: objectId,
|
|
2337
|
+
writeToken: siteWriteToken,
|
|
2338
|
+
finalize: false
|
|
2339
|
+
})
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// Update profile update timestamp
|
|
2343
|
+
await this.ReplaceMetadata({
|
|
2344
|
+
libraryId,
|
|
2345
|
+
objectId,
|
|
2346
|
+
writeToken: streamWriteToken,
|
|
2347
|
+
metadataSubtree: "public/asset_metadata/profile_last_updated",
|
|
2348
|
+
metadata: new Date().toISOString()
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
await this.StreamAssignProfile({
|
|
2352
|
+
profileSlug,
|
|
2353
|
+
streamObjectId: objectId,
|
|
2354
|
+
writeToken: siteWriteToken,
|
|
2355
|
+
finalize: false
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
if(finalize) {
|
|
2359
|
+
await this.FinalizeContentObject({
|
|
2360
|
+
libraryId,
|
|
2361
|
+
objectId,
|
|
2362
|
+
writeToken: streamWriteToken,
|
|
2363
|
+
commitMessage: "Update profile"
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
await this.FinalizeContentObject({
|
|
2367
|
+
libraryId: siteLibraryId,
|
|
2368
|
+
objectId: siteObjectId,
|
|
2369
|
+
writeToken: siteWriteToken,
|
|
2370
|
+
commitMessage: "Update profile streams"
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
if(!finalize) {
|
|
2375
|
+
return {streamWriteToken, siteWriteToken};
|
|
2376
|
+
} else {
|
|
2377
|
+
return {config};
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* Update the live recording configuration of a stream object.
|
|
2383
|
+
*
|
|
2384
|
+
* @methodGroup Live Stream
|
|
2385
|
+
* @namedParams
|
|
2386
|
+
* @param {string} libraryId - Library ID of the stream object
|
|
2387
|
+
* @param {string} objectId - Object ID of the stream
|
|
2388
|
+
* @param {string} commitMessage - Message to include about this commit
|
|
2389
|
+
* @param {string=} writeToken - Write token for the stream object. If not provided, a new edit will be opened.
|
|
2390
|
+
* @param {LiveRecordingConfig} liveRecordingConfig - The live recording configuration to write
|
|
2391
|
+
* @param {Object=} overrideSettings - Partial LiveRecordingConfig deep-merged over liveRecordingConfig
|
|
2392
|
+
* @param {boolean=} finalize - If enabled, the stream object will be finalized after the update (default: true)
|
|
2393
|
+
*
|
|
2394
|
+
* @returns {Promise<{writeToken: string}|void>} - The write token if finalize is false, otherwise void
|
|
2395
|
+
*/
|
|
2396
|
+
exports.StreamUpdateConfig = async function({
|
|
2397
|
+
libraryId,
|
|
2398
|
+
objectId,
|
|
2399
|
+
writeToken,
|
|
2400
|
+
liveRecordingConfig,
|
|
2401
|
+
overrideSettings,
|
|
2402
|
+
commitMessage,
|
|
2403
|
+
finalize=true
|
|
2404
|
+
}) {
|
|
2405
|
+
if(!writeToken) {
|
|
2406
|
+
({writeToken} = await this.EditContentObject({
|
|
2407
|
+
libraryId,
|
|
2408
|
+
objectId
|
|
2409
|
+
}));
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
if(!libraryId) {
|
|
2413
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
if(overrideSettings) {
|
|
2417
|
+
await this.ReplaceMetadata({
|
|
2418
|
+
libraryId,
|
|
2419
|
+
objectId,
|
|
2420
|
+
writeToken,
|
|
2421
|
+
metadataSubtree: "live_recording_overrides",
|
|
2422
|
+
metadata: overrideSettings
|
|
2423
|
+
});
|
|
2424
|
+
|
|
2425
|
+
liveRecordingConfig = R.mergeDeepRight(liveRecordingConfig, overrideSettings);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
await this.ReplaceMetadata({
|
|
2429
|
+
libraryId,
|
|
2430
|
+
objectId,
|
|
2431
|
+
writeToken,
|
|
2432
|
+
metadataSubtree: "live_recording_config",
|
|
2433
|
+
metadata: liveRecordingConfig
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
if(finalize) {
|
|
2437
|
+
await this.FinalizeContentObject({
|
|
2438
|
+
libraryId,
|
|
2439
|
+
objectId,
|
|
2440
|
+
writeToken,
|
|
2441
|
+
commitMessage: commitMessage ?? "Update stream config"
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if(!finalize) {
|
|
2446
|
+
return {writeToken};
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* @typedef {Object} InputStreamInfo
|
|
2452
|
+
* @property {Object=} input_stream_info - Simplified probe information for the input stream
|
|
2453
|
+
* @property {Object=} input_stream_info.format - Format information
|
|
2454
|
+
* @property {string=} input_stream_info.format.format_name - Format name (e.g., "mpegts")
|
|
2455
|
+
* @property {string=} input_stream_info.format.filename - Stream URL
|
|
2456
|
+
* @property {Array<Object>=} input_stream_info.streams - Array of stream information
|
|
2457
|
+
* @property {string=} input_stream_info.streams[].codec_name - Codec name (e.g., "h264", "aac")
|
|
2458
|
+
* @property {string=} input_stream_info.streams[].codec_type - Codec type ("video" or "audio")
|
|
2459
|
+
* @property {string=} input_stream_info.streams[].display_aspect_ratio - Display aspect ratio (e.g., "16/9")
|
|
2460
|
+
* @property {string=} input_stream_info.streams[].field_order - Field order (e.g., "progressive")
|
|
2461
|
+
* @property {string=} input_stream_info.streams[].frame_rate - Frame rate (e.g., "50")
|
|
2462
|
+
* @property {number=} input_stream_info.streams[].height - Video height in pixels
|
|
2463
|
+
* @property {number=} input_stream_info.streams[].width - Video width in pixels
|
|
2464
|
+
* @property {number=} input_stream_info.streams[].level - Codec level
|
|
2465
|
+
* @property {number=} input_stream_info.streams[].stream_id - Stream ID
|
|
2466
|
+
* @property {number=} input_stream_info.streams[].stream_index - Stream index
|
|
2467
|
+
* @property {number=} input_stream_info.streams[].channel_layout - Audio channel layout
|
|
2468
|
+
* @property {number=} input_stream_info.streams[].channels - Number of audio channels
|
|
2469
|
+
* @property {number=} input_stream_info.streams[].sample_rate - Audio sample rate
|
|
2470
|
+
*/
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* @typedef {Object} LiveRecordingConfig
|
|
2474
|
+
* @property {string=} name - Name of the profile
|
|
2475
|
+
*
|
|
2476
|
+
* @property {Object=} recording_config - Recording configuration settings
|
|
2477
|
+
* @property {number=} recording_config.part_ttl - Time-to-live for stream parts in seconds
|
|
2478
|
+
* @property {number=} recording_config.connection_timeout - Initial connection timeout when starting the stream, in seconds
|
|
2479
|
+
* @property {number=} recording_config.reconnect_timeout - Duration to listen after disconnect detection, in seconds
|
|
2480
|
+
* @property {boolean=} recording_config.copy_mpegts - Whether to copy MPEG-TS data
|
|
2481
|
+
* @property {Object=} recording_config.input_cfg - Input configuration settings
|
|
2482
|
+
* @property {boolean=} recording_config.input_cfg.bypass_libav_reader - Whether to bypass libav reader
|
|
2483
|
+
* @property {string=} recording_config.input_cfg.copy_mode - Copy mode setting: "" (empty), "none", "raw", or "remuxed"
|
|
2484
|
+
* @property {string=} recording_config.input_cfg.copy_packaging - Copy packaging mode: "raw_ts" or "rtp_ts"
|
|
2485
|
+
* @property {boolean=} recording_config.input_cfg.custom_read_loop_enabled - Legacy reader
|
|
2486
|
+
* @property {string=} recording_config.input_cfg.input_packaging - Input Packaging
|
|
2487
|
+
*
|
|
2488
|
+
* @property {Object=} playout_config - Playout configuration settings
|
|
2489
|
+
* @property {Object=} playout_config.image_watermark - Image watermark configuration
|
|
2490
|
+
* @property {string=} playout_config.image_watermark.align_h - Horizontal alignment (e.g., "left", "center", "right")
|
|
2491
|
+
* @property {string=} playout_config.image_watermark.align_v - Vertical alignment (e.g., "top", "middle", "bottom")
|
|
2492
|
+
* @property {string=} playout_config.image_watermark.image - Path to watermark image file
|
|
2493
|
+
* @property {boolean=} playout_config.image_watermark.wm_enabled - Whether the image watermark is enabled
|
|
2494
|
+
* @property {Object=} playout_config.simple_watermark - Simple text watermark configuration
|
|
2495
|
+
* @property {string=} playout_config.simple_watermark.font_color - Font color (e.g., "white@0.5")
|
|
2496
|
+
* @property {number=} playout_config.simple_watermark.font_relative_height - Font size relative to video height
|
|
2497
|
+
* @property {boolean=} playout_config.simple_watermark.shadow - Whether to add shadow to text
|
|
2498
|
+
* @property {string=} playout_config.simple_watermark.shadow_color - Shadow color (e.g., "black@0.5")
|
|
2499
|
+
* @property {string=} playout_config.simple_watermark.template - Watermark text template
|
|
2500
|
+
* @property {string=} playout_config.simple_watermark.x - Horizontal position expression
|
|
2501
|
+
* @property {string=} playout_config.simple_watermark.y - Vertical position expression
|
|
2502
|
+
* @property {boolean=} playout_config.dvr - Whether to enable DVR functionality
|
|
2503
|
+
* TODO: update possible drm types
|
|
2504
|
+
* TODO: update possible playout formats
|
|
2505
|
+
* @property {Array<string>=} playout_config.playout_formats - List of playout format names (e.g., "dash-widevine", "hls-widevine")
|
|
2506
|
+
* @property {Object=} playout_config.ladder_specs - Encoding ladder specifications
|
|
2507
|
+
* @property {Array<Object>=} playout_config.ladder_specs.audio - Audio encoding ladder
|
|
2508
|
+
* @property {number=} playout_config.ladder_specs.audio[].bit_rate - Audio bitrate
|
|
2509
|
+
* @property {number=} playout_config.ladder_specs.audio[].channels - Number of audio channels
|
|
2510
|
+
* @property {string=} playout_config.ladder_specs.audio[].codecs - Audio codec identifier
|
|
2511
|
+
* @property {Array<Object>=} playout_config.ladder_specs.video - Video encoding ladder
|
|
2512
|
+
* @property {number=} playout_config.ladder_specs.video[].bit_rate - Video bitrate
|
|
2513
|
+
* @property {string=} playout_config.ladder_specs.video[].codecs - Video codec identifier
|
|
2514
|
+
* @property {number=} playout_config.ladder_specs.video[].height - Video height in pixels
|
|
2515
|
+
* @property {number=} playout_config.ladder_specs.video[].width - Video width in pixels
|
|
2516
|
+
*
|
|
2517
|
+
* @property {Object=} recording_stream_config - Stream recording configuration
|
|
2518
|
+
* @property {Object=} recording_stream_config.audio - Audio stream recording configuration indexed by stream number
|
|
2519
|
+
* @property {number=} recording_stream_config.audio[].bitrate - Stream bitrate
|
|
2520
|
+
* @property {string=} recording_stream_config.audio[].codec - Audio codec (e.g., "aac")
|
|
2521
|
+
* @property {boolean=} recording_stream_config.audio[].playout - Whether to include this stream in playout
|
|
2522
|
+
* @property {string=} recording_stream_config.audio[].playout_label - Label for playout (e.g., "Audio 1")
|
|
2523
|
+
* @property {boolean=} recording_stream_config.audio[].record - Whether to record this audio stream
|
|
2524
|
+
* @property {number=} recording_stream_config.audio[].recording_bitrate - Recording bitrate
|
|
2525
|
+
* @property {number=} recording_stream_config.audio[].recording_channels - Number of recording channels
|
|
2526
|
+
*
|
|
2527
|
+
* @property {InputStreamInfo=} input_stream_info - Simplified probe information for the input stream
|
|
2528
|
+
*
|
|
2529
|
+
* @property {Object=} recording_params - Advanced recording parameters
|
|
2530
|
+
* @property {Object=} recording_params.xc_params - Transcoding parameters
|
|
2531
|
+
* @property {number=} recording_params.xc_params.audio_bitrate - Audio bitrate for encoding
|
|
2532
|
+
* @property {Object=} recording_params.xc_params.audio_index - Audio stream index mapping (indexed by output stream number)
|
|
2533
|
+
* @property {number=} recording_params.xc_params.audio_seg_duration_ts - Audio segment duration in time scale units
|
|
2534
|
+
* @property {number=} recording_params.xc_params.connection_timeout - Connection timeout in seconds
|
|
2535
|
+
* @property {boolean=} recording_params.xc_params.copy_mpegts - Whether to copy MPEG-TS data
|
|
2536
|
+
* @property {string=} recording_params.xc_params.ecodec2 - Audio encoder codec (e.g., "aac")
|
|
2537
|
+
* @property {number=} recording_params.xc_params.enc_height - Encoding height in pixels
|
|
2538
|
+
* @property {number=} recording_params.xc_params.enc_width - Encoding width in pixels
|
|
2539
|
+
* @property {string=} recording_params.xc_params.filter_descriptor - FFmpeg filter descriptor string
|
|
2540
|
+
* @property {number=} recording_params.xc_params.force_keyint - Force keyframe interval
|
|
2541
|
+
* @property {string=} recording_params.xc_params.format - Output format (e.g., "fmp4-segment")
|
|
2542
|
+
* @property {boolean=} recording_params.xc_params.listen - Whether to listen for incoming stream
|
|
2543
|
+
* @property {number=} recording_params.xc_params.n_audio - Number of audio streams
|
|
2544
|
+
* @property {string=} recording_params.xc_params.preset - Encoding preset (e.g., "faster", "medium", "slow")
|
|
2545
|
+
* @property {number=} recording_params.xc_params.sample_rate - Audio sample rate in Hz
|
|
2546
|
+
* @property {string=} recording_params.xc_params.seg_duration - Segment duration in seconds (as string)
|
|
2547
|
+
* @property {boolean=} recording_params.xc_params.skip_decoding - Whether to skip decoding
|
|
2548
|
+
* @property {string=} recording_params.xc_params.start_segment_str - Starting segment number (as string)
|
|
2549
|
+
* @property {number=} recording_params.xc_params.stream_id - Stream ID (-1 for auto)
|
|
2550
|
+
* @property {number=} recording_params.xc_params.sync_audio_to_stream_id - Stream ID to sync audio to
|
|
2551
|
+
* @property {number=} recording_params.xc_params.video_bitrate - Video bitrate for encoding
|
|
2552
|
+
* @property {number=} recording_params.xc_params.video_frame_duration_ts - Video frame duration in time scale units (null for auto)
|
|
2553
|
+
* @property {number=} recording_params.xc_params.video_seg_duration_ts - Video segment duration in time scale units
|
|
2554
|
+
* @property {string=} recording_params.xc_params.video_time_base - Video time base (null for auto)
|
|
2555
|
+
* @property {number=} recording_params.xc_params.xc_type - Transcoding type identifier
|
|
2556
|
+
*
|
|
2557
|
+
* @property {Object=} probe_info - Full probe information (stored for historical/debugging purposes, only in live_recording_config)
|
|
2558
|
+
*
|
|
2559
|
+
*/
|
|
2560
|
+
|
|
2561
|
+
/**
|
|
2562
|
+
* Configure the stream based on built-in logic and optional custom settings.
|
|
2563
|
+
*
|
|
2564
|
+
* @methodGroup Live Stream
|
|
2565
|
+
* @namedParams
|
|
2566
|
+
* @param {string} name - Object ID or name of the live stream object
|
|
2567
|
+
* @param {LiveRecordingConfig=} liveRecordingConfig - Configuration profile for the live stream including recording, playout, and transcoding settings
|
|
2568
|
+
* @param {InputStreamInfo=} inputStreamInfo - Simplified probe metadata
|
|
2569
|
+
* @param {string=} writeToken - Write token of the draft
|
|
2570
|
+
* @param {boolean=} finalize - If enabled, target object will be finalized after configuring
|
|
2571
|
+
*
|
|
2572
|
+
* @return {Promise<Object>} - The status response for the stream
|
|
2573
|
+
*
|
|
2574
|
+
*/
|
|
2575
|
+
exports.StreamConfig = async function({
|
|
2576
|
+
name,
|
|
2577
|
+
liveRecordingConfig,
|
|
2578
|
+
inputStreamInfo,
|
|
2579
|
+
writeToken,
|
|
2580
|
+
finalize=true
|
|
2581
|
+
}) {
|
|
2582
|
+
const objectId = name;
|
|
2583
|
+
let probe = inputStreamInfo || liveRecordingConfig?.input_stream_info;
|
|
2584
|
+
|
|
2585
|
+
const currentStatus = await this.StreamStatus({name, writeToken});
|
|
2586
|
+
|
|
2587
|
+
if(!["uninitialized", "inactive", "unconfigured"].includes(currentStatus.state)) {
|
|
2588
|
+
return {
|
|
2589
|
+
state: currentStatus.state,
|
|
2590
|
+
error: "Stream still active - must deactivate first"
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const libraryId = await this.ContentObjectLibraryId({objectId});
|
|
2595
|
+
|
|
2596
|
+
const status = {
|
|
2597
|
+
name,
|
|
2598
|
+
libraryId: libraryId,
|
|
2599
|
+
objectId: objectId,
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
let liveRecordingConfigProfile;
|
|
2603
|
+
if(liveRecordingConfig && Object.keys(liveRecordingConfig || {}).length > 0) {
|
|
2604
|
+
// Extract values that may have been saved during Create but aren't being repeated in the Config step
|
|
2605
|
+
const savedConfigData = await this.ContentObjectMetadata({
|
|
2606
|
+
libraryId,
|
|
2607
|
+
objectId,
|
|
2608
|
+
writeToken,
|
|
2609
|
+
metadataSubtree: "/live_recording_config"
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
liveRecordingConfigProfile = R.mergeDeepRight(savedConfigData ?? {}, liveRecordingConfig);
|
|
2613
|
+
} else {
|
|
2614
|
+
const lrcMeta = await this.ContentObjectMetadata({
|
|
2615
|
+
libraryId: libraryId,
|
|
2616
|
+
objectId,
|
|
2617
|
+
writeToken,
|
|
2618
|
+
metadataSubtree: "/live_recording_config",
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
// Save liveRecordingConfig as saved profile or default
|
|
2622
|
+
liveRecordingConfigProfile = lrcMeta ?? LRCProfile;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
let nodeId = liveRecordingConfigProfile?.ingress_node_id;
|
|
2626
|
+
|
|
2627
|
+
status.userConfig = liveRecordingConfigProfile;
|
|
2628
|
+
|
|
2629
|
+
// If the stored probe is from a different protocol than the current URL, discard it and re-probe
|
|
2630
|
+
if(probe && !inputStreamInfo) {
|
|
2631
|
+
const urlProtocol = liveRecordingConfigProfile.url?.split(":")?.[0];
|
|
2632
|
+
const probeProtocol = (probe.format?.filename || "").split(":")?.[0];
|
|
2633
|
+
if(urlProtocol && probeProtocol && urlProtocol !== probeProtocol) {
|
|
2634
|
+
probe = null;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
const streamData = {
|
|
2639
|
+
client: this
|
|
2640
|
+
};
|
|
2641
|
+
|
|
2642
|
+
if(nodeId) {
|
|
2643
|
+
streamData.nodeId = nodeId;
|
|
2644
|
+
streamData.nodeApi = liveRecordingConfigProfile?.url;
|
|
2645
|
+
} else {
|
|
2646
|
+
streamData.url = liveRecordingConfigProfile.url;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// Get node URI from user config
|
|
2650
|
+
let node, endpoint, streamHref;
|
|
2651
|
+
try {
|
|
2652
|
+
({node, endpoint, streamHref} = await GetNodeFromStreamData(streamData));
|
|
2653
|
+
status.node = node;
|
|
2654
|
+
nodeId = node.id;
|
|
2655
|
+
} catch(error) {
|
|
2656
|
+
throw error;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// No stream data provided ; probe the stream for info
|
|
2660
|
+
if(!probe) {
|
|
2661
|
+
probe = await GetStreamProbe({
|
|
2662
|
+
client: this,
|
|
2663
|
+
libraryId,
|
|
2664
|
+
objectId,
|
|
2665
|
+
streamHref,
|
|
2666
|
+
endpoint
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// Create live recording config
|
|
2671
|
+
const liveConf = new LiveConf({
|
|
2672
|
+
url: liveRecordingConfigProfile.url,
|
|
2673
|
+
probeData: probe,
|
|
2674
|
+
nodeId,
|
|
2675
|
+
nodeUrl: endpoint,
|
|
2676
|
+
includeAVSegDurations: false,
|
|
2677
|
+
overwriteOriginUrl: false,
|
|
2678
|
+
syncAudioToVideo: true
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
const liveRecordingConfigMeta = liveConf.generateLiveConf({
|
|
2682
|
+
customSettings: {
|
|
2683
|
+
liveRecordingConfigProfile
|
|
2684
|
+
}
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
// Store live recording config into the stream object
|
|
2688
|
+
if(!writeToken) {
|
|
2689
|
+
let e = await this.EditContentObject({
|
|
2690
|
+
libraryId,
|
|
2691
|
+
objectId: objectId
|
|
2692
|
+
});
|
|
2693
|
+
writeToken = e.write_token;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
if(["uninitialized", "unconfigured"].includes(currentStatus.state)) {
|
|
2697
|
+
const formats = liveRecordingConfigMeta?.live_recording.playout_config?.playout_formats;
|
|
2698
|
+
|
|
2699
|
+
await this.StreamInitialize({
|
|
2700
|
+
name: objectId,
|
|
2701
|
+
drm: (formats || []).some(el => !el.includes("clear")) ? true : false,
|
|
2702
|
+
format: formats ? formats?.join(",") : "",
|
|
2703
|
+
writeToken,
|
|
2704
|
+
finalize: false
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const allowList = ["fabric_config", "playout_config", "recording_config", "url"];
|
|
2709
|
+
const filteredMeta = Object.fromEntries(
|
|
2710
|
+
Object.entries(liveRecordingConfigMeta.live_recording || {}).filter(([key]) => allowList.includes(key))
|
|
2711
|
+
);
|
|
2712
|
+
|
|
2713
|
+
await this.ReplaceMetadata({
|
|
2714
|
+
libraryId,
|
|
2715
|
+
objectId,
|
|
2716
|
+
writeToken,
|
|
2717
|
+
metadataSubtree: "live_recording",
|
|
2718
|
+
metadata: filteredMeta
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
await this.ReplaceMetadata({
|
|
2722
|
+
libraryId,
|
|
2723
|
+
objectId,
|
|
2724
|
+
writeToken,
|
|
2725
|
+
metadataSubtree: "live_recording_config/probe_info",
|
|
2726
|
+
metadata: probe
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
if(finalize) {
|
|
2730
|
+
status.fin = await this.FinalizeContentObject({
|
|
2731
|
+
libraryId,
|
|
2732
|
+
objectId,
|
|
2733
|
+
writeToken,
|
|
2734
|
+
commitMessage: "Apply live stream configuration"
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
return status;
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
/**
|
|
2742
|
+
* List the pre-allocated URLs for a site
|
|
2743
|
+
*
|
|
2744
|
+
* @methodGroup Live Stream
|
|
2745
|
+
* @namedParams
|
|
2746
|
+
* @param {string=} siteId - ID of the live stream site object
|
|
2747
|
+
*
|
|
2748
|
+
* @return {Promise<Object>} - The list of stream URLs
|
|
2749
|
+
*/
|
|
2750
|
+
exports.StreamListUrls = async function({siteId}={}) {
|
|
2751
|
+
try {
|
|
2752
|
+
const STATUS_MAP = {
|
|
2753
|
+
UNCONFIGURED: "unconfigured",
|
|
2754
|
+
UNINITIALIZED: "uninitialized",
|
|
2755
|
+
INACTIVE: "inactive",
|
|
2756
|
+
STOPPED: "stopped",
|
|
2757
|
+
STARTING: "starting",
|
|
2758
|
+
RUNNING: "running",
|
|
2759
|
+
STALLED: "stalled",
|
|
2760
|
+
};
|
|
2761
|
+
|
|
2762
|
+
if(!siteId) {
|
|
2763
|
+
const tenantContractId = await this.userProfileClient.TenantContractId();
|
|
2764
|
+
|
|
2765
|
+
if(!tenantContractId) {
|
|
2766
|
+
throw Error("No tenant contract ID configured");
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
siteId = await this.ContentObjectMetadata({
|
|
2770
|
+
libraryId: tenantContractId.replace("iten", "ilib"),
|
|
2771
|
+
objectId: tenantContractId.replace("iten", "iq__"),
|
|
2772
|
+
metadataSubtree: "public/sites/live_streams",
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const streamMetadata = await this.ContentObjectMetadata({
|
|
2777
|
+
libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
|
|
2778
|
+
objectId: siteId,
|
|
2779
|
+
metadataSubtree: "public/asset_metadata/live_streams",
|
|
2780
|
+
resolveLinks: true,
|
|
2781
|
+
resolveIgnoreErrors: true,
|
|
2782
|
+
resolveIncludeSource: true
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
const activeUrlMap = {};
|
|
2786
|
+
await this.utils.LimitedMap(
|
|
2787
|
+
10,
|
|
2788
|
+
Object.keys(streamMetadata || {}),
|
|
2789
|
+
async slug => {
|
|
2790
|
+
const stream = streamMetadata[slug];
|
|
2791
|
+
let versionHash;
|
|
2792
|
+
|
|
2793
|
+
if(stream && stream["."] && stream["."].source) {
|
|
2794
|
+
versionHash = stream["."].source;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
if(versionHash) {
|
|
2798
|
+
const objectId = this.utils.DecodeVersionHash(versionHash).objectId;
|
|
2799
|
+
const libraryId = await this.ContentObjectLibraryId({objectId});
|
|
2800
|
+
|
|
2801
|
+
const status = await this.StreamStatus({
|
|
2802
|
+
name: objectId
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
const streamMeta = await this.ContentObjectMetadata({
|
|
2806
|
+
objectId,
|
|
2807
|
+
libraryId,
|
|
2808
|
+
select: [
|
|
2809
|
+
// live_recording_config/reference_url is an old path
|
|
2810
|
+
"live_recording_config/reference_url",
|
|
2811
|
+
"live_recording_config/ingress_node_api",
|
|
2812
|
+
// live_recording_config/url is the old path
|
|
2813
|
+
"live_recording_config/url"
|
|
2814
|
+
]
|
|
2815
|
+
}) || {};
|
|
2816
|
+
|
|
2817
|
+
const url = streamMeta.live_recording_config?.ingress_node_api || streamMeta.live_recording_config?.reference_url || streamMeta.live_recording_config?.url;
|
|
2818
|
+
const isActive = [STATUS_MAP.STARTING, STATUS_MAP.RUNNING, STATUS_MAP.STALLED, STATUS_MAP.STOPPED].includes(status.state);
|
|
2819
|
+
|
|
2820
|
+
if(url && isActive) {
|
|
2821
|
+
activeUrlMap[url] = true;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
);
|
|
2826
|
+
|
|
2827
|
+
const streamUrlStatus = {};
|
|
2828
|
+
|
|
2829
|
+
const streamUrls = await this.ContentObjectMetadata({
|
|
2830
|
+
libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
|
|
2831
|
+
objectId: siteId,
|
|
2832
|
+
metadataSubtree: "/live_stream_urls",
|
|
2833
|
+
resolveLinks: true,
|
|
2834
|
+
resolveIgnoreErrors: true
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
if(!streamUrls) {
|
|
2838
|
+
throw Error("No pre-allocated URLs configured");
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
Object.keys(streamUrls || {}).forEach(protocol => {
|
|
2842
|
+
streamUrlStatus[protocol] = streamUrls[protocol].map(url => {
|
|
2843
|
+
return {
|
|
2844
|
+
url,
|
|
2845
|
+
active: activeUrlMap[url] || false
|
|
2846
|
+
};
|
|
2847
|
+
});
|
|
2848
|
+
});
|
|
2849
|
+
|
|
2850
|
+
return streamUrlStatus;
|
|
2851
|
+
} catch(error) {
|
|
2852
|
+
console.error(error);
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
/**
|
|
2857
|
+
* Copy a portion of a live stream recording into a standard VoD object using the zero-copy content fabric API
|
|
2858
|
+
*
|
|
2859
|
+
* Limitations:
|
|
2860
|
+
* - currently requires the target object to be pre-created and have content encryption keys (CAPS)
|
|
2861
|
+
* - for audio and video to be sync'd, the live stream needs to have the beginning of the desired recording period
|
|
2862
|
+
* - for an event stream, make sure the TTL is long enough to allow running the live-to-vod command before the beginning of the recording expires
|
|
2863
|
+
* - for 24/7 streams, make sure to reset the stream before the desired recording (as to create a new recording period) and have the TTL long enough
|
|
2864
|
+
* to allow running the live-to-vod command before the beginning of the recording expires.
|
|
2865
|
+
* - startTime and endTime are not currently implemented by this method
|
|
2866
|
+
*
|
|
1620
2867
|
*
|
|
1621
2868
|
* @methodGroup Live Stream
|
|
1622
2869
|
* @namedParams
|
|
@@ -1626,457 +2873,1169 @@ exports.StreamListUrls = async function({siteId}={}) {
|
|
|
1626
2873
|
* @param {boolean=} finalize - If enabled, target object will be finalized after copy to vod operations
|
|
1627
2874
|
* @param {number=} recordingPeriod - Determines which recording period to copy, which are 0-based. -1 copies the current (or last) period
|
|
1628
2875
|
*
|
|
1629
|
-
* @return {Promise<Object>} - The status response for the stream
|
|
2876
|
+
* @return {Promise<Object>} - The status response for the stream
|
|
2877
|
+
*/
|
|
2878
|
+
|
|
2879
|
+
/*
|
|
2880
|
+
Example fabric API flow:
|
|
2881
|
+
|
|
2882
|
+
https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/live_to_vod/init -d @r1 -H "Authorization: Bearer $TOK"
|
|
2883
|
+
|
|
2884
|
+
{
|
|
2885
|
+
"live_qhash": "hq__5Zk1jSN8vNLUAXjQwMJV8F8J8ESXNvmVKkhaXySmGc1BXnJPG2FvvaXee4CXqvFHuGuU3fqLJc",
|
|
2886
|
+
"start_time": "",
|
|
2887
|
+
"end_time": "",
|
|
2888
|
+
"recording_period": -1,
|
|
2889
|
+
"streams": ["video", "audio"],
|
|
2890
|
+
"variant_key": "default"
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/init -H "Authorization: Bearer $TOK" -d @r2
|
|
2894
|
+
|
|
2895
|
+
{
|
|
2896
|
+
|
|
2897
|
+
"abr_profile": { ... },
|
|
2898
|
+
"offering_key": "default",
|
|
2899
|
+
"prod_master_hash": "tqw__HSQHBt7vYxWfCMPH5yXwKTfhdPcQ4Lcs9WUMUbTtnMbTZPTLo4BfJWPMGpoy1Dpv1wWQVtUtAtAr429TnVs",
|
|
2900
|
+
"variant_key": "default",
|
|
2901
|
+
"keep_other_streams": false
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/live_to_vod/copy -d '{"variant_key":"","offering_key":""}' -H "Authorization: Bearer $TOK"
|
|
2905
|
+
|
|
2906
|
+
|
|
2907
|
+
https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/offerings/default/finalize -d '{}' -H "Authorization: Bearer $TOK"
|
|
2908
|
+
|
|
2909
|
+
*/
|
|
2910
|
+
|
|
2911
|
+
exports.StreamCopyToVod = async function({
|
|
2912
|
+
name,
|
|
2913
|
+
targetObjectId,
|
|
2914
|
+
eventId,
|
|
2915
|
+
streams=null,
|
|
2916
|
+
finalize=true,
|
|
2917
|
+
recordingPeriod=-1,
|
|
2918
|
+
startTime="",
|
|
2919
|
+
endTime=""
|
|
2920
|
+
}) {
|
|
2921
|
+
const objectId = name;
|
|
2922
|
+
const abrProfile = require("../abr_profiles/abr_profile_live_to_vod.js");
|
|
2923
|
+
|
|
2924
|
+
const status = await this.StreamStatus({name});
|
|
2925
|
+
const libraryId = status.libraryId;
|
|
2926
|
+
|
|
2927
|
+
this.Log(`Copying stream ${name} to target ${targetObjectId}`);
|
|
2928
|
+
|
|
2929
|
+
ValidateObject(targetObjectId);
|
|
2930
|
+
|
|
2931
|
+
const targetLibraryId = await this.ContentObjectLibraryId({objectId: targetObjectId});
|
|
2932
|
+
|
|
2933
|
+
// Validation - ensure target object has content encryption keys
|
|
2934
|
+
const kmsAddress = await this.authClient.KMSAddress({objectId: targetObjectId});
|
|
2935
|
+
const kmsCapId = `eluv.caps.ikms${this.utils.AddressToHash(kmsAddress)}`;
|
|
2936
|
+
const kmsCap = await this.ContentObjectMetadata({
|
|
2937
|
+
libraryId: targetLibraryId,
|
|
2938
|
+
objectId: targetObjectId,
|
|
2939
|
+
metadataSubtree: kmsCapId
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
if(!kmsCap) {
|
|
2943
|
+
throw Error(`No content encryption key set for object ${targetObjectId}`);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
try {
|
|
2947
|
+
status.liveObjectId = objectId;
|
|
2948
|
+
|
|
2949
|
+
const liveHash = await this.LatestVersionHash({objectId, libraryId});
|
|
2950
|
+
status.liveHash = liveHash;
|
|
2951
|
+
|
|
2952
|
+
if(eventId) {
|
|
2953
|
+
// Retrieve start and end times for the event
|
|
2954
|
+
let event = await this.CueInfo({eventId, status});
|
|
2955
|
+
if(event.eventStart && event.eventEnd) {
|
|
2956
|
+
startTime = event.eventStart;
|
|
2957
|
+
endTime = event.eventEnd;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
const {writeToken} = await this.EditContentObject({
|
|
2962
|
+
objectId: targetObjectId,
|
|
2963
|
+
libraryId: targetLibraryId
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
status.targetObjectId = targetObjectId;
|
|
2967
|
+
status.targetLibraryId = targetLibraryId;
|
|
2968
|
+
status.targetWriteToken = writeToken;
|
|
2969
|
+
|
|
2970
|
+
this.Log("Process live source (takes around 20 sec per hour of content)");
|
|
2971
|
+
|
|
2972
|
+
await this.CallBitcodeMethod({
|
|
2973
|
+
libraryId: targetLibraryId,
|
|
2974
|
+
objectId: targetObjectId,
|
|
2975
|
+
writeToken,
|
|
2976
|
+
method: "/media/live_to_vod/init",
|
|
2977
|
+
body: {
|
|
2978
|
+
"live_qhash": liveHash,
|
|
2979
|
+
"start_time": startTime, // eg. "2023-10-03T02:09:02.00Z",
|
|
2980
|
+
"end_time": endTime, // eg. "2023-10-03T02:15:00.00Z",
|
|
2981
|
+
"streams": streams,
|
|
2982
|
+
"recording_period": recordingPeriod,
|
|
2983
|
+
"variant_key": "default"
|
|
2984
|
+
},
|
|
2985
|
+
constant: false,
|
|
2986
|
+
format: "text"
|
|
2987
|
+
});
|
|
2988
|
+
|
|
2989
|
+
const abrMezInitBody = {
|
|
2990
|
+
abr_profile: abrProfile,
|
|
2991
|
+
"offering_key": "default",
|
|
2992
|
+
"prod_master_hash": writeToken,
|
|
2993
|
+
"variant_key": "default",
|
|
2994
|
+
"keep_other_streams": false
|
|
2995
|
+
};
|
|
2996
|
+
|
|
2997
|
+
await this.CallBitcodeMethod({
|
|
2998
|
+
libraryId: targetLibraryId,
|
|
2999
|
+
objectId: targetObjectId,
|
|
3000
|
+
writeToken,
|
|
3001
|
+
method: "/media/abr_mezzanine/init",
|
|
3002
|
+
body: abrMezInitBody,
|
|
3003
|
+
constant: false,
|
|
3004
|
+
format: "text"
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
try {
|
|
3008
|
+
await this.CallBitcodeMethod({
|
|
3009
|
+
libraryId: targetLibraryId,
|
|
3010
|
+
objectId: targetObjectId,
|
|
3011
|
+
writeToken,
|
|
3012
|
+
method: "/media/live_to_vod/copy",
|
|
3013
|
+
body: {},
|
|
3014
|
+
constant: false,
|
|
3015
|
+
format: "text"
|
|
3016
|
+
});
|
|
3017
|
+
} catch(error) {
|
|
3018
|
+
console.error("Unable to call /media/live_to_vod/copy", error);
|
|
3019
|
+
throw error;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
await this.CallBitcodeMethod({
|
|
3023
|
+
libraryId: targetLibraryId,
|
|
3024
|
+
objectId: targetObjectId,
|
|
3025
|
+
writeToken,
|
|
3026
|
+
method: "/media/abr_mezzanine/offerings/default/finalize",
|
|
3027
|
+
body: abrMezInitBody,
|
|
3028
|
+
constant: false,
|
|
3029
|
+
format: "text"
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
if(finalize) {
|
|
3033
|
+
const finalizeResponse = await this.FinalizeContentObject({
|
|
3034
|
+
libraryId: targetLibraryId,
|
|
3035
|
+
objectId: targetObjectId,
|
|
3036
|
+
writeToken,
|
|
3037
|
+
commitMessage: "Live Stream to VoD"
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
status.targetHash = finalizeResponse.hash;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// Clean up unnecessary status items
|
|
3044
|
+
delete status.playoutUrls;
|
|
3045
|
+
delete status.lroStatusUrl;
|
|
3046
|
+
delete status.recordingPeriod;
|
|
3047
|
+
delete status.recordingPeriodSequence;
|
|
3048
|
+
delete status.edgeMetaSize;
|
|
3049
|
+
delete status.insertions;
|
|
3050
|
+
|
|
3051
|
+
return status;
|
|
3052
|
+
} catch(error) {
|
|
3053
|
+
this.Log(error, true);
|
|
3054
|
+
throw error;
|
|
3055
|
+
}
|
|
3056
|
+
};
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* Remove a watermark for a live stream
|
|
3060
|
+
*
|
|
3061
|
+
* @methodGroup Live Stream
|
|
3062
|
+
* @namedParams
|
|
3063
|
+
* @param {string=} libraryId - Library ID of the live stream
|
|
3064
|
+
* @param {string} objectId - Object ID of the live stream
|
|
3065
|
+
* @param {string=} writeToken - Write token of the draft
|
|
3066
|
+
* @param {Array<string>} types - Specify which type of watermark to remove. Possible values:
|
|
3067
|
+
* - "image"
|
|
3068
|
+
* - "text"
|
|
3069
|
+
* - "forensic"
|
|
3070
|
+
* @param {boolean=} finalize - If enabled, target object will be finalized after removing watermark
|
|
3071
|
+
*
|
|
3072
|
+
* @return {Promise<Object>} - The finalize response
|
|
3073
|
+
*/
|
|
3074
|
+
exports.StreamRemoveWatermark = async function({
|
|
3075
|
+
libraryId,
|
|
3076
|
+
objectId,
|
|
3077
|
+
writeToken,
|
|
3078
|
+
types,
|
|
3079
|
+
finalize=true
|
|
3080
|
+
}) {
|
|
3081
|
+
ValidateObject(objectId);
|
|
3082
|
+
|
|
3083
|
+
if(!libraryId) {
|
|
3084
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
if(!writeToken) {
|
|
3088
|
+
({writeToken} = await this.EditContentObject({
|
|
3089
|
+
objectId,
|
|
3090
|
+
libraryId
|
|
3091
|
+
}));
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
this.Log(`Removing watermark types: ${types.join(", ")} ${libraryId} ${objectId}`);
|
|
3095
|
+
|
|
3096
|
+
const edgeWriteToken = await this.ContentObjectMetadata({
|
|
3097
|
+
objectId,
|
|
3098
|
+
libraryId,
|
|
3099
|
+
metadataSubtree: "/live_recording/fabric_config/edge_write_token"
|
|
3100
|
+
});
|
|
3101
|
+
|
|
3102
|
+
const metadataPath = "live_recording/playout_config";
|
|
3103
|
+
|
|
3104
|
+
const objectMetadata = await this.ContentObjectMetadata({
|
|
3105
|
+
libraryId,
|
|
3106
|
+
objectId,
|
|
3107
|
+
writeToken,
|
|
3108
|
+
metadataSubtree: metadataPath,
|
|
3109
|
+
resolveLinks: false
|
|
3110
|
+
});
|
|
3111
|
+
|
|
3112
|
+
if(!objectMetadata) {
|
|
3113
|
+
throw Error("Stream object must be configured before removing a watermark");
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
types.forEach(type => {
|
|
3117
|
+
if(type === "text") {
|
|
3118
|
+
delete objectMetadata.simple_watermark;
|
|
3119
|
+
} else if(type === "image") {
|
|
3120
|
+
delete objectMetadata.image_watermark;
|
|
3121
|
+
} else if(type === "forensic") {
|
|
3122
|
+
delete objectMetadata.forensic_watermark;
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
await this.ReplaceMetadata({
|
|
3127
|
+
libraryId,
|
|
3128
|
+
objectId,
|
|
3129
|
+
writeToken,
|
|
3130
|
+
metadataSubtree: metadataPath,
|
|
3131
|
+
metadata: objectMetadata
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
if(edgeWriteToken) {
|
|
3135
|
+
await this.ReplaceMetadata({
|
|
3136
|
+
libraryId,
|
|
3137
|
+
objectId,
|
|
3138
|
+
writeToken: edgeWriteToken,
|
|
3139
|
+
metadataSubtree: metadataPath,
|
|
3140
|
+
metadata: objectMetadata
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
if(finalize) {
|
|
3145
|
+
const finalizeResponse = await this.FinalizeContentObject({
|
|
3146
|
+
libraryId,
|
|
3147
|
+
objectId,
|
|
3148
|
+
writeToken,
|
|
3149
|
+
commitMessage: "Watermark removed"
|
|
3150
|
+
});
|
|
3151
|
+
|
|
3152
|
+
return finalizeResponse;
|
|
3153
|
+
}
|
|
3154
|
+
};
|
|
3155
|
+
|
|
3156
|
+
/**
|
|
3157
|
+
* Create a watermark for a live stream
|
|
3158
|
+
*
|
|
3159
|
+
* @methodGroup Live Stream
|
|
3160
|
+
* @namedParams
|
|
3161
|
+
* @param {string=} libraryId - Library ID of the live stream
|
|
3162
|
+
* @param {string} objectId - Object ID of the live stream
|
|
3163
|
+
* @param {string=} writeToken - Write token of the draft
|
|
3164
|
+
* @param {Object} simpleWatermark - Text watermark
|
|
3165
|
+
* @param {Object} imageWatermark - Image watermark
|
|
3166
|
+
* @param {Object} forensicWatermark - Forensic watermark
|
|
3167
|
+
* @param {boolean=} finalize - If enabled, target object will be finalized after adding watermark
|
|
3168
|
+
* Watermark examples:
|
|
3169
|
+
*
|
|
3170
|
+
* Simple Watermark:
|
|
3171
|
+
{
|
|
3172
|
+
"font_color": "",
|
|
3173
|
+
"font_relative_height": 0,
|
|
3174
|
+
"shadow": false,
|
|
3175
|
+
"template": "",
|
|
3176
|
+
"timecode": "",
|
|
3177
|
+
"timecode_rate": 0,
|
|
3178
|
+
"x": "",
|
|
3179
|
+
"y": ""
|
|
3180
|
+
}
|
|
3181
|
+
*
|
|
3182
|
+
* Image watermark:
|
|
3183
|
+
{
|
|
3184
|
+
"image": "",
|
|
3185
|
+
"align_h": "",
|
|
3186
|
+
"align_v": "",
|
|
3187
|
+
"target_video_height": 0,
|
|
3188
|
+
"wm_enabled": false
|
|
3189
|
+
}
|
|
3190
|
+
*
|
|
3191
|
+
* Forensic watermark:
|
|
3192
|
+
{
|
|
3193
|
+
"algo": 6,
|
|
3194
|
+
"forensic_duration": 0,
|
|
3195
|
+
"forensic_start": "",
|
|
3196
|
+
"image_a": <path_to_image>,
|
|
3197
|
+
"image_b": <path_to_image>,
|
|
3198
|
+
"is_stub": true,
|
|
3199
|
+
"payload_bit_nb": 23,
|
|
3200
|
+
"wm_enabled": true
|
|
3201
|
+
}
|
|
3202
|
+
*
|
|
3203
|
+
*
|
|
3204
|
+
* @return {Promise<Object>} - The finalize response
|
|
3205
|
+
*/
|
|
3206
|
+
exports.StreamAddWatermark = async function({
|
|
3207
|
+
libraryId,
|
|
3208
|
+
objectId,
|
|
3209
|
+
writeToken,
|
|
3210
|
+
simpleWatermark,
|
|
3211
|
+
imageWatermark,
|
|
3212
|
+
forensicWatermark,
|
|
3213
|
+
finalize=true
|
|
3214
|
+
}) {
|
|
3215
|
+
ValidateObject(objectId);
|
|
3216
|
+
|
|
3217
|
+
if(!libraryId) {
|
|
3218
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
if(!writeToken) {
|
|
3222
|
+
({writeToken} = await this.EditContentObject({
|
|
3223
|
+
objectId,
|
|
3224
|
+
libraryId
|
|
3225
|
+
}));
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
const edgeWriteToken = await this.ContentObjectMetadata({
|
|
3229
|
+
objectId,
|
|
3230
|
+
libraryId,
|
|
3231
|
+
metadataSubtree: "/live_recording/fabric_config/edge_write_token"
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
const watermarkType = imageWatermark ? "image" : forensicWatermark ? "forensic" : "text";
|
|
3235
|
+
const metadataPath = "live_recording/playout_config";
|
|
3236
|
+
|
|
3237
|
+
this.Log(`Adding watermarking type: ${watermarkType} ${libraryId} ${objectId}`);
|
|
3238
|
+
|
|
3239
|
+
const objectMetadata = await this.ContentObjectMetadata({
|
|
3240
|
+
libraryId,
|
|
3241
|
+
objectId,
|
|
3242
|
+
writeToken,
|
|
3243
|
+
metadataSubtree: metadataPath,
|
|
3244
|
+
resolveLinks: false
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
if(!objectMetadata) {
|
|
3248
|
+
throw Error("Stream object must be configured before adding a watermark");
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
const watermarkArgCount = [simpleWatermark, imageWatermark, forensicWatermark].filter(i => !!i).length;
|
|
3252
|
+
console.log("watermark arg count", watermarkArgCount);
|
|
3253
|
+
|
|
3254
|
+
if(watermarkArgCount === 0) {
|
|
3255
|
+
throw Error("No watermark was provided");
|
|
3256
|
+
} else if(watermarkArgCount > 1) {
|
|
3257
|
+
throw Error("Only one watermark is allowed");
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
if(simpleWatermark) {
|
|
3261
|
+
objectMetadata.simple_watermark = simpleWatermark;
|
|
3262
|
+
} else if(imageWatermark) {
|
|
3263
|
+
objectMetadata.image_watermark = imageWatermark;
|
|
3264
|
+
} else if(forensicWatermark) {
|
|
3265
|
+
objectMetadata.forensic_watermark = forensicWatermark;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
await this.ReplaceMetadata({
|
|
3269
|
+
libraryId,
|
|
3270
|
+
objectId,
|
|
3271
|
+
writeToken,
|
|
3272
|
+
metadataSubtree: metadataPath,
|
|
3273
|
+
metadata: objectMetadata
|
|
3274
|
+
});
|
|
3275
|
+
|
|
3276
|
+
if(edgeWriteToken) {
|
|
3277
|
+
await this.ReplaceMetadata({
|
|
3278
|
+
libraryId,
|
|
3279
|
+
objectId,
|
|
3280
|
+
writeToken: edgeWriteToken,
|
|
3281
|
+
metadataSubtree: metadataPath,
|
|
3282
|
+
metadata: objectMetadata
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
const response = {
|
|
3287
|
+
"imageWatermark": objectMetadata.image_watermark,
|
|
3288
|
+
"textWatermark": objectMetadata.simple_watermark,
|
|
3289
|
+
"forensicWatermark": objectMetadata.forensic_watermark
|
|
3290
|
+
};
|
|
3291
|
+
|
|
3292
|
+
if(finalize) {
|
|
3293
|
+
const finalizeResponse = await this.FinalizeContentObject({
|
|
3294
|
+
libraryId,
|
|
3295
|
+
objectId,
|
|
3296
|
+
writeToken,
|
|
3297
|
+
commitMessage: "Watermark set"
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
response.hash = finalizeResponse.hash;
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
return response;
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
/**
|
|
3307
|
+
* Audit the specified live stream against several content fabric nodes
|
|
3308
|
+
*
|
|
3309
|
+
* @methodGroup Live Stream
|
|
3310
|
+
* @namedParams
|
|
3311
|
+
* @param {string=} objectId - Object ID of the live stream
|
|
3312
|
+
* @param {string=} versionHash - Version hash of the live stream -- if not specified, latest version is returned
|
|
3313
|
+
* @param {string=} salt - base64-encoded byte sequence for salting the audit hash
|
|
3314
|
+
* @param {Array<number>=} samples - list of percentages (0.0 - <1.0) used for sampling the content part list, up to 3
|
|
3315
|
+
* @param {string=} authorizationToken - Additional authorization token for this request
|
|
3316
|
+
*
|
|
3317
|
+
* @returns {Promise<Object>} - Response describing audit results
|
|
3318
|
+
*/
|
|
3319
|
+
exports.AuditStream = async function({objectId, versionHash, salt, samples, authorizationToken}) {
|
|
3320
|
+
return await ContentObjectAudit.AuditContentObject({
|
|
3321
|
+
client: this,
|
|
3322
|
+
objectId,
|
|
3323
|
+
versionHash,
|
|
3324
|
+
salt,
|
|
3325
|
+
samples,
|
|
3326
|
+
live: true,
|
|
3327
|
+
authorizationToken
|
|
3328
|
+
});
|
|
3329
|
+
};
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* @typedef {Object} LiveOutput
|
|
3333
|
+
* @property {boolean=} enabled - Whether the output is enabled
|
|
3334
|
+
* @property {string=} name - Display name for the output
|
|
3335
|
+
* @property {string=} description - Description of the output
|
|
3336
|
+
* @property {string=} external_id - External identifier for the output
|
|
3337
|
+
* @property {boolean=} reset - Whether to reset the output
|
|
3338
|
+
* @property {Object=} input - Input stream configuration
|
|
3339
|
+
* @property {string=} input.stream - Object ID of the input stream (null to disconnect)
|
|
3340
|
+
* @property {Object=} srt_pull - SRT pull delivery configuration
|
|
3341
|
+
* @property {Array<string>=} srt_pull.node_ids - Egress node IDs for SRT delivery (max 1)
|
|
3342
|
+
* @property {string=} srt_pull.passphrase - SRT passphrase for encrypted delivery
|
|
3343
|
+
* @property {boolean=} srt_pull.strip_rtp - Whether to strip RTP headers
|
|
3344
|
+
* @property {Object=} srt_pull.connection - Additional SRT connection configuration
|
|
3345
|
+
* @property {Array<string>=} srt_pull.urls - SRT URLs (returned by server, not set by caller)
|
|
3346
|
+
*/
|
|
3347
|
+
|
|
3348
|
+
// Resolve egress node and replace SRT URL with the egress endpoint hostname.
|
|
3349
|
+
// Necessary because the backend API doesn't return the proper SRT URLs currently.
|
|
3350
|
+
exports.OutputsResolveSrtPullUrls = async function({value}) {
|
|
3351
|
+
const nodeId = value.srt_pull?.node_ids?.[0];
|
|
3352
|
+
if(!nodeId) { return value; }
|
|
3353
|
+
|
|
3354
|
+
const nodes = await this.SpaceNodes({matchNodeId: nodeId});
|
|
3355
|
+
const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
|
|
3356
|
+
if(fabricUrl) {
|
|
3357
|
+
const egressHost = new URL(fabricUrl).hostname;
|
|
3358
|
+
if(value.srt_pull?.urls) {
|
|
3359
|
+
value.srt_pull.urls = value.srt_pull.urls.map(url =>
|
|
3360
|
+
url.replace(/^srt:\/\/[^:/?]+/, `srt://${egressHost}`)
|
|
3361
|
+
);
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
return value;
|
|
3366
|
+
};
|
|
3367
|
+
|
|
3368
|
+
/**
|
|
3369
|
+
* List all live outputs for a stream object, optionally including live state.
|
|
3370
|
+
*
|
|
3371
|
+
* @methodGroup Live Stream
|
|
3372
|
+
* @namedParams
|
|
3373
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3374
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3375
|
+
* @param {boolean=} includeState - If true, also retrieve live state from each output's egress node (default: true)
|
|
3376
|
+
*
|
|
3377
|
+
* @returns {Promise<Object<string, LiveOutput>>} - Map of output IDs to LiveOutput objects, each optionally with a `state` field
|
|
3378
|
+
*/
|
|
3379
|
+
exports.OutputsList = async function({libraryId, objectId, includeState=true}) {
|
|
3380
|
+
ValidateObject(objectId);
|
|
3381
|
+
|
|
3382
|
+
if(!libraryId) {
|
|
3383
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// Route to any live egress node for the initial list call (only necessary until the API is globally available)
|
|
3387
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
3388
|
+
|
|
3389
|
+
let outputs;
|
|
3390
|
+
try {
|
|
3391
|
+
outputs = await this.CallBitcodeMethod({
|
|
3392
|
+
libraryId,
|
|
3393
|
+
objectId,
|
|
3394
|
+
method: "live/outputs",
|
|
3395
|
+
constant: true
|
|
3396
|
+
});
|
|
3397
|
+
} finally {
|
|
3398
|
+
restore();
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
for(let [key, value] of Object.entries(outputs)) {
|
|
3402
|
+
const streamId = value.input?.stream;
|
|
3403
|
+
|
|
3404
|
+
if(streamId) {
|
|
3405
|
+
const streamMetadata = await this.ContentObjectMetadata({
|
|
3406
|
+
libraryId: await this.ContentObjectLibraryId({objectId: streamId}),
|
|
3407
|
+
objectId: streamId,
|
|
3408
|
+
metadataSubtree: "/public/name",
|
|
3409
|
+
});
|
|
3410
|
+
|
|
3411
|
+
const streamStatus = await this.StreamStatus({name: streamId});
|
|
3412
|
+
|
|
3413
|
+
value.input.name = streamMetadata;
|
|
3414
|
+
value.input.status = streamStatus?.state;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
if(includeState) {
|
|
3418
|
+
await this.OutputsResolveSrtPullUrls({value});
|
|
3419
|
+
try {
|
|
3420
|
+
const nodeId = value.srt_pull?.node_ids?.[0];
|
|
3421
|
+
const result = await this.OutputsState({outputId: key, objectId, libraryId, nodeId, includeState: true});
|
|
3422
|
+
value.state = result.state;
|
|
3423
|
+
} catch(error) {
|
|
3424
|
+
this.Log(`Failed to retrieve state for output ${key}: ${error.message}`, true);
|
|
3425
|
+
value.state = {};
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
return outputs;
|
|
3431
|
+
};
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* Get the configuration of a single live output by ID, optionally including live state.
|
|
3435
|
+
*
|
|
3436
|
+
* @methodGroup Live Stream
|
|
3437
|
+
* @namedParams
|
|
3438
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3439
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3440
|
+
* @param {string} outputId - ID of the output to retrieve
|
|
3441
|
+
* @param {boolean=} includeState - If true, also retrieve live state from the output's egress node (default: true)
|
|
3442
|
+
*
|
|
3443
|
+
* @returns {Promise<LiveOutput>} - Output config, optionally with a `state` field containing client_stats and srt_stats
|
|
1630
3444
|
*/
|
|
3445
|
+
exports.OutputsListItem = async function({libraryId, objectId, outputId, includeState=true}) {
|
|
3446
|
+
ValidateObject(objectId);
|
|
3447
|
+
ValidatePresence("outputId", outputId);
|
|
1631
3448
|
|
|
1632
|
-
|
|
1633
|
-
|
|
3449
|
+
if(!libraryId) {
|
|
3450
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3451
|
+
}
|
|
1634
3452
|
|
|
1635
|
-
|
|
3453
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
1636
3454
|
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
3455
|
+
let outputs;
|
|
3456
|
+
try {
|
|
3457
|
+
outputs = await this.CallBitcodeMethod({
|
|
3458
|
+
libraryId,
|
|
3459
|
+
objectId,
|
|
3460
|
+
method: "live/outputs",
|
|
3461
|
+
constant: true
|
|
3462
|
+
});
|
|
3463
|
+
} finally {
|
|
3464
|
+
restore();
|
|
3465
|
+
}
|
|
1645
3466
|
|
|
1646
|
-
|
|
3467
|
+
let value = outputs[outputId];
|
|
1647
3468
|
|
|
1648
|
-
|
|
3469
|
+
if(!value) {
|
|
3470
|
+
throw new Error(`Output not found: ${outputId}`);
|
|
3471
|
+
}
|
|
1649
3472
|
|
|
1650
|
-
|
|
1651
|
-
"offering_key": "default",
|
|
1652
|
-
"prod_master_hash": "tqw__HSQHBt7vYxWfCMPH5yXwKTfhdPcQ4Lcs9WUMUbTtnMbTZPTLo4BfJWPMGpoy1Dpv1wWQVtUtAtAr429TnVs",
|
|
1653
|
-
"variant_key": "default",
|
|
1654
|
-
"keep_other_streams": false
|
|
1655
|
-
}
|
|
3473
|
+
const streamId = value.input?.stream;
|
|
1656
3474
|
|
|
1657
|
-
|
|
3475
|
+
if(streamId) {
|
|
3476
|
+
const streamMetadata = await this.ContentObjectMetadata({
|
|
3477
|
+
libraryId: await this.ContentObjectLibraryId({objectId: streamId}),
|
|
3478
|
+
objectId: streamId,
|
|
3479
|
+
metadataSubtree: "/public/name",
|
|
3480
|
+
});
|
|
1658
3481
|
|
|
3482
|
+
const streamStatus = await this.StreamStatus({name: streamId});
|
|
1659
3483
|
|
|
1660
|
-
|
|
3484
|
+
value.input.name = streamMetadata;
|
|
3485
|
+
value.input.status = streamStatus?.state;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
await this.OutputsResolveSrtPullUrls({value});
|
|
3489
|
+
|
|
3490
|
+
if(includeState) {
|
|
3491
|
+
try {
|
|
3492
|
+
const nodeId = value.srt_pull?.node_ids?.[0];
|
|
3493
|
+
const result = await this.OutputsState({outputId, objectId, libraryId, nodeId, includeState: true});
|
|
3494
|
+
value.state = result.state;
|
|
3495
|
+
} catch(error) {
|
|
3496
|
+
this.Log(`Failed to retrieve state for output ${outputId}: ${error.message}`, true);
|
|
3497
|
+
value.state = {};
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
return value;
|
|
3502
|
+
};
|
|
1661
3503
|
|
|
3504
|
+
/**
|
|
3505
|
+
* Get the configuration of a specific live output, optionally including live state.
|
|
3506
|
+
* Retrieves the output config from a live egress node. If includeState is true, also
|
|
3507
|
+
* queries the output's specific egress node for live client and SRT stats.
|
|
3508
|
+
*
|
|
3509
|
+
* @methodGroup Live Stream
|
|
3510
|
+
* @namedParams
|
|
3511
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3512
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3513
|
+
* @param {string} outputId - ID of the output to retrieve state for
|
|
3514
|
+
* @param {string=} nodeId - Node ID to query for state. If not provided, it will be retrieved from the output's config.
|
|
3515
|
+
* @param {boolean=} includeState - If true, also retrieve live state from the output's egress node (default: true)
|
|
3516
|
+
*
|
|
3517
|
+
* @returns {Promise<LiveOutput>} - Output config, optionally with a `state` field containing client_stats and srt_stats
|
|
1662
3518
|
*/
|
|
3519
|
+
exports.OutputsState = async function({libraryId, objectId, outputId, nodeId, includeState=true}) {
|
|
3520
|
+
ValidateObject(objectId);
|
|
3521
|
+
ValidatePresence("outputId", outputId);
|
|
1663
3522
|
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
eventId,
|
|
1668
|
-
streams=null,
|
|
1669
|
-
finalize=true,
|
|
1670
|
-
recordingPeriod=-1,
|
|
1671
|
-
startTime="",
|
|
1672
|
-
endTime=""
|
|
1673
|
-
}) {
|
|
1674
|
-
const objectId = name;
|
|
1675
|
-
const abrProfile = require("../abr_profiles/abr_profile_live_to_vod.js");
|
|
3523
|
+
if(!libraryId) {
|
|
3524
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3525
|
+
}
|
|
1676
3526
|
|
|
1677
|
-
|
|
1678
|
-
const
|
|
3527
|
+
// Route to a live egress node first so the output config fetch below succeeds
|
|
3528
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
1679
3529
|
|
|
1680
|
-
|
|
3530
|
+
try {
|
|
3531
|
+
const {config} = await RouteToOutputNode({client: this, libraryId, objectId, outputId, nodeId});
|
|
1681
3532
|
|
|
1682
|
-
|
|
3533
|
+
if(!includeState) {
|
|
3534
|
+
return config;
|
|
3535
|
+
}
|
|
1683
3536
|
|
|
1684
|
-
|
|
3537
|
+
const state = await this.CallBitcodeMethod({
|
|
3538
|
+
libraryId,
|
|
3539
|
+
objectId,
|
|
3540
|
+
method: UrlJoin("live", "outputs", outputId, "state"),
|
|
3541
|
+
queryParams: {
|
|
3542
|
+
"client_stats": 1,
|
|
3543
|
+
"srt_stats": 1
|
|
3544
|
+
},
|
|
3545
|
+
constant: true
|
|
3546
|
+
});
|
|
1685
3547
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
3548
|
+
return {
|
|
3549
|
+
...config,
|
|
3550
|
+
state
|
|
3551
|
+
};
|
|
3552
|
+
} finally {
|
|
3553
|
+
restore();
|
|
3554
|
+
}
|
|
3555
|
+
};
|
|
1694
3556
|
|
|
1695
|
-
|
|
1696
|
-
|
|
3557
|
+
/**
|
|
3558
|
+
* Pin (route) the client to the egress node for a specific output. Fetches the output config
|
|
3559
|
+
* to determine the node ID then sets the client fabric URIs.
|
|
3560
|
+
* Currently node ID is retrieved from srt_pull.node_ids (only srt_pull outputs are supported).
|
|
3561
|
+
* Assumes the client is already routed to an eligible egress node (via RouteToLiveEgress).
|
|
3562
|
+
* Returns a function that restores the original fabric URIs.
|
|
3563
|
+
*
|
|
3564
|
+
* @param {Object} client - ElvClient instance
|
|
3565
|
+
* @param {string} libraryId - Library ID of the output settings object
|
|
3566
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3567
|
+
* @param {string} outputId - ID of the output
|
|
3568
|
+
* @param {string=} nodeId - Node ID if already known (skips config fetch)
|
|
3569
|
+
* @returns {Promise<{restore: Function, config: Object}>} - restore function and output config
|
|
3570
|
+
*/
|
|
3571
|
+
const RouteToOutputNode = async ({client, libraryId, objectId, outputId, nodeId}) => {
|
|
3572
|
+
const savedURIs = [...client.fabricURIs];
|
|
3573
|
+
const restore = () => client.SetNodes({fabricURIs: savedURIs});
|
|
3574
|
+
|
|
3575
|
+
let config;
|
|
3576
|
+
if(!nodeId) {
|
|
3577
|
+
config = await client.CallBitcodeMethod({
|
|
3578
|
+
libraryId,
|
|
3579
|
+
objectId,
|
|
3580
|
+
method: UrlJoin("live", "outputs", outputId),
|
|
3581
|
+
constant: true
|
|
3582
|
+
});
|
|
3583
|
+
nodeId = config?.srt_pull?.node_ids?.[0];
|
|
1697
3584
|
}
|
|
1698
3585
|
|
|
1699
|
-
|
|
1700
|
-
|
|
3586
|
+
if(nodeId) {
|
|
3587
|
+
const nodes = await client.SpaceNodes({matchNodeId: nodeId});
|
|
3588
|
+
const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
|
|
3589
|
+
if(fabricUrl) {
|
|
3590
|
+
client.SetNodes({fabricURIs: [fabricUrl]});
|
|
3591
|
+
if(config) { await client.OutputsResolveSrtPullUrls({value: config}); }
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
1701
3594
|
|
|
1702
|
-
|
|
1703
|
-
|
|
3595
|
+
return {restore, config};
|
|
3596
|
+
};
|
|
1704
3597
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
3598
|
+
/**
|
|
3599
|
+
* Pin (route) the client to any eligible live egress node from the /config API.
|
|
3600
|
+
* Returns a function that restores the original fabric URIs.
|
|
3601
|
+
*
|
|
3602
|
+
* @param {Object} client - ElvClient instance
|
|
3603
|
+
* @returns {Promise<{restore: Function}>} - restore function
|
|
3604
|
+
*/
|
|
3605
|
+
const RouteToLiveEgress = async ({client}) => {
|
|
3606
|
+
const savedURIs = [...client.fabricURIs];
|
|
3607
|
+
const restore = () => client.SetNodes({fabricURIs: savedURIs});
|
|
3608
|
+
|
|
3609
|
+
const nodeId = await RetrieveOutputNodeId({client});
|
|
3610
|
+
|
|
3611
|
+
if(nodeId) {
|
|
3612
|
+
const nodes = await client.SpaceNodes({matchNodeId: nodeId});
|
|
3613
|
+
const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
|
|
3614
|
+
if(fabricUrl) {
|
|
3615
|
+
client.SetNodes({fabricURIs: [fabricUrl]});
|
|
1712
3616
|
}
|
|
3617
|
+
}
|
|
1713
3618
|
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
libraryId: targetLibraryId
|
|
1717
|
-
});
|
|
3619
|
+
return {restore};
|
|
3620
|
+
};
|
|
1718
3621
|
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
3622
|
+
/**
|
|
3623
|
+
* Resolve a node ID for live egress output. If nodeIds is provided, uses the first element directly.
|
|
3624
|
+
* Otherwise, calls the /config API (optionally filtered by geo) to get live_egress endpoints,
|
|
3625
|
+
* then resolves the first endpoint to a node ID via SpaceNodes.
|
|
3626
|
+
*
|
|
3627
|
+
* @param {Object} client - ElvClient instance
|
|
3628
|
+
* @param {Array<string>=} nodeIds - Explicit node IDs to use
|
|
3629
|
+
* @param {Array<string>=} geos - Geo regions to filter config API results (max 1)
|
|
3630
|
+
* @returns {Promise<string>} - A node ID for the output
|
|
3631
|
+
*/
|
|
3632
|
+
const RetrieveOutputNodeId = async ({client, nodeIds, geos}) => {
|
|
3633
|
+
if(nodeIds) {
|
|
3634
|
+
return nodeIds[0];
|
|
3635
|
+
}
|
|
1722
3636
|
|
|
1723
|
-
|
|
3637
|
+
const uri = new URI(client.ConfigUrl());
|
|
3638
|
+
uri.pathname("/config");
|
|
3639
|
+
if(geos && geos.length > 0) {
|
|
3640
|
+
uri.addSearch("elvgeo", geos[0]);
|
|
3641
|
+
}
|
|
1724
3642
|
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
writeToken,
|
|
1729
|
-
method: "/media/live_to_vod/init",
|
|
1730
|
-
body: {
|
|
1731
|
-
"live_qhash": liveHash,
|
|
1732
|
-
"start_time": startTime, // eg. "2023-10-03T02:09:02.00Z",
|
|
1733
|
-
"end_time": endTime, // eg. "2023-10-03T02:15:00.00Z",
|
|
1734
|
-
"streams": streams,
|
|
1735
|
-
"recording_period": recordingPeriod,
|
|
1736
|
-
"variant_key": "default"
|
|
1737
|
-
},
|
|
1738
|
-
constant: false,
|
|
1739
|
-
format: "text"
|
|
1740
|
-
});
|
|
3643
|
+
const fabricInfo = await client.utils.ResponseToJson(
|
|
3644
|
+
HttpClient.Fetch(uri.toString())
|
|
3645
|
+
);
|
|
1741
3646
|
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
3647
|
+
const liveEgressUrls = fabricInfo.network.services.live_egress;
|
|
3648
|
+
if(!liveEgressUrls || liveEgressUrls.length === 0) {
|
|
3649
|
+
throw new Error("No live_egress endpoints found in fabric config");
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
// Extract hostname from the first live_egress URL and resolve to a node ID
|
|
3653
|
+
const hostname = new URL(liveEgressUrls[0]).hostname;
|
|
3654
|
+
const nodes = await client.SpaceNodes({matchEndpoint: hostname});
|
|
3655
|
+
if(!nodes || nodes.length === 0) {
|
|
3656
|
+
throw new Error(`No node found matching live_egress endpoint: ${hostname}`);
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
return nodes[0].id;
|
|
3660
|
+
};
|
|
3661
|
+
|
|
3662
|
+
/**
|
|
3663
|
+
* Create a new live output.
|
|
3664
|
+
*
|
|
3665
|
+
* At the current version of the live outputs API an output will be pinned to a node, by either:
|
|
3666
|
+
* - specifying the node directly in 'nodeIds'
|
|
3667
|
+
* - specifying an 'elvgeo' and use fabric config 'live_egress' services to pick a node ID
|
|
3668
|
+
*
|
|
3669
|
+
* Note: Output creation and modification is transactional. To create multiple outputs in a single
|
|
3670
|
+
* transaction, use EditContentObject to open a write token, call CallBitcodeMethod for each output,
|
|
3671
|
+
* then finalize with FinalizeContentObject. This method handles a single output end-to-end.
|
|
3672
|
+
*
|
|
3673
|
+
* @methodGroup Live Stream
|
|
3674
|
+
* @namedParams
|
|
3675
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3676
|
+
* @param {string} objectId - Object ID of the outputs settings object
|
|
3677
|
+
* @param {string=} streamObjectId - Object ID of the input stream to use as the output source
|
|
3678
|
+
* @param {string=} name - Display name for the output
|
|
3679
|
+
* @param {string=} description - Description of the output
|
|
3680
|
+
* @param {Array<string>=} nodeIds - Explicit node ID(s) for SRT delivery (max 1)
|
|
3681
|
+
* @param {Array<string>=} geos - Geo regions for SRT delivery (max 1) — used to resolve a node from live_egress endpoints
|
|
3682
|
+
* @param {string=} passphrase - SRT passphrase for encrypted delivery
|
|
3683
|
+
* @param {boolean=} stripRtp - Whether to strip RTP headers (default: false)
|
|
3684
|
+
* @param {Object=} srtConfig - Additional SRT connection configuration (see openapi-bitcode.html#tocssrtconnectionconfig)
|
|
3685
|
+
*
|
|
3686
|
+
* @returns {Promise<Object>} - The created output
|
|
3687
|
+
*/
|
|
3688
|
+
exports.OutputsCreate = async function({
|
|
3689
|
+
libraryId,
|
|
3690
|
+
objectId,
|
|
3691
|
+
streamObjectId,
|
|
3692
|
+
enabled,
|
|
3693
|
+
name,
|
|
3694
|
+
description,
|
|
3695
|
+
externalId,
|
|
3696
|
+
nodeIds,
|
|
3697
|
+
geos=[],
|
|
3698
|
+
passphrase,
|
|
3699
|
+
stripRtp=false,
|
|
3700
|
+
srtConfig
|
|
3701
|
+
}) {
|
|
3702
|
+
ValidateObject(objectId);
|
|
3703
|
+
|
|
3704
|
+
if(nodeIds && geos.length > 0) {
|
|
3705
|
+
throw new Error("Specify either nodeIds or geos, not both");
|
|
3706
|
+
}
|
|
3707
|
+
if(nodeIds && nodeIds.length > 1) {
|
|
3708
|
+
throw new Error("Only one node ID is supported — nodeIds must have at most 1 element");
|
|
3709
|
+
}
|
|
3710
|
+
if(geos.length > 1) {
|
|
3711
|
+
throw new Error("Only one geo is supported — geos must have at most 1 element");
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
if(!libraryId) {
|
|
3715
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
const resolvedNodeId = await RetrieveOutputNodeId({client: this, nodeIds, geos});
|
|
3719
|
+
|
|
3720
|
+
// Route to any live egress node
|
|
3721
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
3722
|
+
|
|
3723
|
+
// Auto-generate passphrase if encryption is truthy
|
|
3724
|
+
if(srtConfig?.enforced_encryption && !passphrase) {
|
|
3725
|
+
passphrase = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(16))).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
3726
|
+
srtConfig["pb_keylen"] = 16;
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
try {
|
|
3730
|
+
const output = {
|
|
3731
|
+
enabled: streamObjectId ? enabled : false, // Output must be disabled if no stream specified
|
|
3732
|
+
name,
|
|
3733
|
+
description,
|
|
3734
|
+
external_id: externalId,
|
|
3735
|
+
input: streamObjectId ? {stream: streamObjectId} : undefined,
|
|
3736
|
+
srt_pull: {
|
|
3737
|
+
connection: srtConfig ?? undefined,
|
|
3738
|
+
node_ids: [resolvedNodeId],
|
|
3739
|
+
passphrase,
|
|
3740
|
+
strip_rtp: stripRtp
|
|
3741
|
+
}
|
|
1748
3742
|
};
|
|
1749
3743
|
|
|
1750
|
-
await this.
|
|
1751
|
-
|
|
1752
|
-
|
|
3744
|
+
const {writeToken} = await this.EditContentObject({libraryId, objectId});
|
|
3745
|
+
|
|
3746
|
+
// Note - you may create multiple outputs here, then finalize the transaction below
|
|
3747
|
+
const outputs = await this.CallBitcodeMethod({
|
|
3748
|
+
libraryId,
|
|
3749
|
+
objectId,
|
|
1753
3750
|
writeToken,
|
|
1754
|
-
method: "/
|
|
1755
|
-
body: abrMezInitBody,
|
|
3751
|
+
method: "live/outputs",
|
|
1756
3752
|
constant: false,
|
|
1757
|
-
|
|
3753
|
+
body: output
|
|
1758
3754
|
});
|
|
1759
3755
|
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
3756
|
+
await this.FinalizeContentObject({
|
|
3757
|
+
libraryId,
|
|
3758
|
+
objectId,
|
|
3759
|
+
writeToken,
|
|
3760
|
+
commitMessage: "Create output"
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
return outputs;
|
|
3764
|
+
} finally {
|
|
3765
|
+
restore();
|
|
3766
|
+
}
|
|
3767
|
+
};
|
|
3768
|
+
|
|
3769
|
+
/**
|
|
3770
|
+
* Modify an existing live output.
|
|
3771
|
+
*
|
|
3772
|
+
* Note: Supply all fields when modifying an output — read the current output first, then apply changes.
|
|
3773
|
+
*
|
|
3774
|
+
* Note: Output modification is transactional. To modify multiple outputs in a single
|
|
3775
|
+
* transaction, use EditContentObject to open a write token, call CallBitcodeMethod for each output,
|
|
3776
|
+
* then finalize with FinalizeContentObject. This method handles a single output end-to-end.
|
|
3777
|
+
*
|
|
3778
|
+
* @methodGroup Live Stream
|
|
3779
|
+
* @namedParams
|
|
3780
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3781
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3782
|
+
* @param {string} outputId - ID of the output to modify
|
|
3783
|
+
* @param {LiveOutput} output - Full output object to PUT (read the current output first, apply changes, then pass the result)
|
|
3784
|
+
* @param {string=} writeToken - Write token to use. If not provided, a new edit will be opened.
|
|
3785
|
+
* @param {boolean=} finalize - If true, finalize after modifying (default: true)
|
|
3786
|
+
*
|
|
3787
|
+
* @returns {Promise<Object>} - The modified output
|
|
3788
|
+
*/
|
|
3789
|
+
exports.OutputsModify = async function({
|
|
3790
|
+
libraryId,
|
|
3791
|
+
objectId,
|
|
3792
|
+
outputId,
|
|
3793
|
+
output,
|
|
3794
|
+
writeToken,
|
|
3795
|
+
finalize=true
|
|
3796
|
+
}) {
|
|
3797
|
+
ValidateObject(objectId);
|
|
3798
|
+
ValidatePresence("output", output);
|
|
3799
|
+
|
|
3800
|
+
if(!libraryId) {
|
|
3801
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
// Route to any live egress node
|
|
3805
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
3806
|
+
|
|
3807
|
+
try {
|
|
3808
|
+
if(!writeToken) {
|
|
3809
|
+
({writeToken} = await this.EditContentObject({libraryId, objectId}));
|
|
1773
3810
|
}
|
|
1774
3811
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
3812
|
+
if(output.srt_pull?.connection?.enforced_encryption && !output.srt_pull?.passphrase) {
|
|
3813
|
+
output.srt_pull.passphrase = Buffer.from(
|
|
3814
|
+
globalThis.crypto.getRandomValues(new Uint8Array(16))
|
|
3815
|
+
).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
const outputs = await this.CallBitcodeMethod({
|
|
3819
|
+
libraryId,
|
|
3820
|
+
objectId,
|
|
1778
3821
|
writeToken,
|
|
1779
|
-
method: "
|
|
1780
|
-
|
|
3822
|
+
method: UrlJoin("live", "outputs", outputId),
|
|
3823
|
+
verb: "PUT",
|
|
1781
3824
|
constant: false,
|
|
1782
|
-
|
|
3825
|
+
body: output
|
|
1783
3826
|
});
|
|
1784
3827
|
|
|
1785
3828
|
if(finalize) {
|
|
1786
|
-
|
|
1787
|
-
libraryId
|
|
1788
|
-
objectId
|
|
3829
|
+
await this.FinalizeContentObject({
|
|
3830
|
+
libraryId,
|
|
3831
|
+
objectId,
|
|
1789
3832
|
writeToken,
|
|
1790
|
-
commitMessage: "
|
|
1791
|
-
});
|
|
1792
|
-
|
|
1793
|
-
status.target_hash = finalizeResponse.hash;
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// Clean up unnecessary status items
|
|
1797
|
-
delete status.playout_urls;
|
|
1798
|
-
delete status.lro_status_url;
|
|
1799
|
-
delete status.recording_period;
|
|
1800
|
-
delete status.recording_period_sequence;
|
|
1801
|
-
delete status.edge_meta_size;
|
|
1802
|
-
delete status.insertions;
|
|
3833
|
+
commitMessage: "Modify output"
|
|
3834
|
+
});
|
|
3835
|
+
}
|
|
1803
3836
|
|
|
1804
|
-
return
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
throw error;
|
|
3837
|
+
return outputs;
|
|
3838
|
+
} finally {
|
|
3839
|
+
restore();
|
|
1808
3840
|
}
|
|
1809
3841
|
};
|
|
1810
3842
|
|
|
1811
3843
|
/**
|
|
1812
|
-
*
|
|
3844
|
+
* Modify multiple live outputs in a single transaction.
|
|
3845
|
+
*
|
|
3846
|
+
* Takes a map of output IDs to partial output configurations. Routes to any eligible live
|
|
3847
|
+
* egress node, opens a write token, posts the map to live/outputs, then finalizes.
|
|
3848
|
+
*
|
|
3849
|
+
* Example:
|
|
3850
|
+
* {
|
|
3851
|
+
* "out001": { "enabled": false, "input": { "stream": "iq__..." }, "name": "A03" },
|
|
3852
|
+
* "out002": { "enabled": true }
|
|
3853
|
+
* }
|
|
1813
3854
|
*
|
|
1814
3855
|
* @methodGroup Live Stream
|
|
1815
3856
|
* @namedParams
|
|
1816
|
-
* @param {string=} libraryId - Library ID of the
|
|
1817
|
-
* @param {string} objectId - Object ID of the
|
|
1818
|
-
* @param {string
|
|
1819
|
-
* @param {Array<string>} types - Specify which type of watermark to remove. Possible values:
|
|
1820
|
-
* - "image"
|
|
1821
|
-
* - "text"
|
|
1822
|
-
* - "forensic"
|
|
1823
|
-
* @param {boolean=} finalize - If enabled, target object will be finalized after removing watermark
|
|
3857
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3858
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3859
|
+
* @param {Object<string, LiveOutput>} outputs - Map of output IDs to output configurations
|
|
1824
3860
|
*
|
|
1825
|
-
* @
|
|
3861
|
+
* @returns {Promise<Object>} - The response from the bitcode call
|
|
1826
3862
|
*/
|
|
1827
|
-
exports.
|
|
1828
|
-
libraryId,
|
|
1829
|
-
objectId,
|
|
1830
|
-
writeToken,
|
|
1831
|
-
types,
|
|
1832
|
-
finalize=true
|
|
1833
|
-
}) {
|
|
3863
|
+
exports.OutputsModifyBatch = async function({libraryId, objectId, outputs}) {
|
|
1834
3864
|
ValidateObject(objectId);
|
|
3865
|
+
ValidatePresence("outputs", outputs);
|
|
1835
3866
|
|
|
1836
3867
|
if(!libraryId) {
|
|
1837
3868
|
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
1838
3869
|
}
|
|
1839
3870
|
|
|
1840
|
-
|
|
1841
|
-
({writeToken} = await this.EditContentObject({
|
|
1842
|
-
objectId,
|
|
1843
|
-
libraryId
|
|
1844
|
-
}));
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
this.Log(`Removing watermark types: ${types.join(", ")} ${libraryId} ${objectId}`);
|
|
1848
|
-
|
|
1849
|
-
const edgeWriteToken = await this.ContentObjectMetadata({
|
|
1850
|
-
objectId,
|
|
1851
|
-
libraryId,
|
|
1852
|
-
metadataSubtree: "/live_recording/fabric_config/edge_write_token"
|
|
1853
|
-
});
|
|
1854
|
-
|
|
1855
|
-
const metadataPath = "live_recording/playout_config";
|
|
1856
|
-
|
|
1857
|
-
const objectMetadata = await this.ContentObjectMetadata({
|
|
1858
|
-
libraryId,
|
|
1859
|
-
objectId,
|
|
1860
|
-
writeToken,
|
|
1861
|
-
metadataSubtree: metadataPath,
|
|
1862
|
-
resolveLinks: false
|
|
1863
|
-
});
|
|
3871
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
1864
3872
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
3873
|
+
try {
|
|
3874
|
+
// Read all current outputs and merge changes on top
|
|
3875
|
+
const current = await this.CallBitcodeMethod({
|
|
3876
|
+
libraryId,
|
|
3877
|
+
objectId,
|
|
3878
|
+
method: "live/outputs",
|
|
3879
|
+
constant: true
|
|
3880
|
+
});
|
|
1868
3881
|
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
} else if(type === "image") {
|
|
1873
|
-
delete objectMetadata.image_watermark;
|
|
1874
|
-
} else if(type === "forensic") {
|
|
1875
|
-
delete objectMetadata.forensic_watermark;
|
|
3882
|
+
const merged = {...current};
|
|
3883
|
+
for(const [id, changes] of Object.entries(outputs)) {
|
|
3884
|
+
merged[id] = {...(current[id] || {}), ...changes};
|
|
1876
3885
|
}
|
|
1877
|
-
});
|
|
1878
3886
|
|
|
1879
|
-
|
|
1880
|
-
libraryId,
|
|
1881
|
-
objectId,
|
|
1882
|
-
writeToken,
|
|
1883
|
-
metadataSubtree: metadataPath,
|
|
1884
|
-
metadata: objectMetadata
|
|
1885
|
-
});
|
|
3887
|
+
const {writeToken} = await this.EditContentObject({libraryId, objectId});
|
|
1886
3888
|
|
|
1887
|
-
|
|
1888
|
-
await this.ReplaceMetadata({
|
|
3889
|
+
const result = await this.CallBitcodeMethod({
|
|
1889
3890
|
libraryId,
|
|
1890
3891
|
objectId,
|
|
1891
|
-
writeToken
|
|
1892
|
-
|
|
1893
|
-
|
|
3892
|
+
writeToken,
|
|
3893
|
+
method: "live/outputs",
|
|
3894
|
+
verb: "PUT",
|
|
3895
|
+
constant: false,
|
|
3896
|
+
body: merged
|
|
1894
3897
|
});
|
|
1895
|
-
}
|
|
1896
3898
|
|
|
1897
|
-
|
|
1898
|
-
const finalizeResponse = await this.FinalizeContentObject({
|
|
3899
|
+
await this.FinalizeContentObject({
|
|
1899
3900
|
libraryId,
|
|
1900
3901
|
objectId,
|
|
1901
3902
|
writeToken,
|
|
1902
|
-
commitMessage: "
|
|
3903
|
+
commitMessage: "Modify outputs (batch)"
|
|
1903
3904
|
});
|
|
1904
3905
|
|
|
1905
|
-
return
|
|
3906
|
+
return result;
|
|
3907
|
+
} finally {
|
|
3908
|
+
restore();
|
|
1906
3909
|
}
|
|
1907
3910
|
};
|
|
1908
3911
|
|
|
1909
3912
|
/**
|
|
1910
|
-
*
|
|
3913
|
+
* Stop a live output.
|
|
1911
3914
|
*
|
|
1912
3915
|
* @methodGroup Live Stream
|
|
1913
3916
|
* @namedParams
|
|
1914
|
-
* @param {string=} libraryId - Library ID of the
|
|
1915
|
-
* @param {string} objectId - Object ID of the
|
|
1916
|
-
* @param {string
|
|
1917
|
-
* @param {Object} simpleWatermark - Text watermark
|
|
1918
|
-
* @param {Object} imageWatermark - Image watermark
|
|
1919
|
-
* @param {Object} forensicWatermark - Forensic watermark
|
|
1920
|
-
* @param {boolean=} finalize - If enabled, target object will be finalized after adding watermark
|
|
1921
|
-
* Watermark examples:
|
|
1922
|
-
*
|
|
1923
|
-
* Simple Watermark:
|
|
1924
|
-
{
|
|
1925
|
-
"font_color": "",
|
|
1926
|
-
"font_relative_height": 0,
|
|
1927
|
-
"shadow": false,
|
|
1928
|
-
"template": "",
|
|
1929
|
-
"timecode": "",
|
|
1930
|
-
"timecode_rate": 0,
|
|
1931
|
-
"x": "",
|
|
1932
|
-
"y": ""
|
|
1933
|
-
}
|
|
1934
|
-
*
|
|
1935
|
-
* Image watermark:
|
|
1936
|
-
{
|
|
1937
|
-
"image": "",
|
|
1938
|
-
"align_h": "",
|
|
1939
|
-
"align_v": "",
|
|
1940
|
-
"target_video_height": 0,
|
|
1941
|
-
"wm_enabled": false
|
|
1942
|
-
}
|
|
1943
|
-
*
|
|
1944
|
-
* Forensic watermark:
|
|
1945
|
-
{
|
|
1946
|
-
"algo": 6,
|
|
1947
|
-
"forensic_duration": 0,
|
|
1948
|
-
"forensic_start": "",
|
|
1949
|
-
"image_a": <path_to_image>,
|
|
1950
|
-
"image_b": <path_to_image>,
|
|
1951
|
-
"is_stub": true,
|
|
1952
|
-
"payload_bit_nb": 23,
|
|
1953
|
-
"wm_enabled": true
|
|
1954
|
-
}
|
|
1955
|
-
*
|
|
3917
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3918
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3919
|
+
* @param {string} outputId - ID of the output to stop
|
|
1956
3920
|
*
|
|
1957
|
-
* @
|
|
3921
|
+
* @returns {Promise<Object>} - Response from the stop call
|
|
1958
3922
|
*/
|
|
1959
|
-
exports.
|
|
1960
|
-
libraryId,
|
|
1961
|
-
objectId,
|
|
1962
|
-
writeToken,
|
|
1963
|
-
simpleWatermark,
|
|
1964
|
-
imageWatermark,
|
|
1965
|
-
forensicWatermark,
|
|
1966
|
-
finalize=true
|
|
1967
|
-
}) {
|
|
3923
|
+
exports.OutputsStop = async function({libraryId, objectId, outputId}) {
|
|
1968
3924
|
ValidateObject(objectId);
|
|
3925
|
+
ValidatePresence("outputId", outputId);
|
|
1969
3926
|
|
|
1970
3927
|
if(!libraryId) {
|
|
1971
3928
|
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
1972
3929
|
}
|
|
1973
3930
|
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
libraryId
|
|
1978
|
-
}));
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
const edgeWriteToken = await this.ContentObjectMetadata({
|
|
1982
|
-
objectId,
|
|
1983
|
-
libraryId,
|
|
1984
|
-
metadataSubtree: "/live_recording/fabric_config/edge_write_token"
|
|
1985
|
-
});
|
|
1986
|
-
|
|
1987
|
-
const watermarkType = imageWatermark ? "image" : forensicWatermark ? "forensic" : "text";
|
|
1988
|
-
const metadataPath = "live_recording/playout_config";
|
|
1989
|
-
|
|
1990
|
-
this.Log(`Adding watermarking type: ${watermarkType} ${libraryId} ${objectId}`);
|
|
3931
|
+
// Route to a live egress node, then to the specific output's node
|
|
3932
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
3933
|
+
await RouteToOutputNode({client: this, libraryId, objectId, outputId});
|
|
1991
3934
|
|
|
1992
|
-
|
|
1993
|
-
libraryId,
|
|
1994
|
-
objectId,
|
|
1995
|
-
writeToken,
|
|
1996
|
-
metadataSubtree: metadataPath,
|
|
1997
|
-
resolveLinks: false
|
|
1998
|
-
});
|
|
3935
|
+
try {
|
|
3936
|
+
const {writeToken} = await this.EditContentObject({libraryId, objectId});
|
|
1999
3937
|
|
|
2000
|
-
|
|
2001
|
-
|
|
3938
|
+
return await this.CallBitcodeMethod({
|
|
3939
|
+
libraryId,
|
|
3940
|
+
objectId,
|
|
3941
|
+
writeToken,
|
|
3942
|
+
method: UrlJoin("live", "outputs", outputId, "ctrl", "stop"),
|
|
3943
|
+
constant: false
|
|
3944
|
+
});
|
|
3945
|
+
} finally {
|
|
3946
|
+
restore();
|
|
2002
3947
|
}
|
|
3948
|
+
};
|
|
2003
3949
|
|
|
2004
|
-
|
|
2005
|
-
|
|
3950
|
+
/**
|
|
3951
|
+
* Delete a live output.
|
|
3952
|
+
*
|
|
3953
|
+
* @methodGroup Live Stream
|
|
3954
|
+
* @namedParams
|
|
3955
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
3956
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
3957
|
+
* @param {string} outputId - ID of the output to delete
|
|
3958
|
+
*
|
|
3959
|
+
* @returns {Promise<Object>} - Response from the delete call
|
|
3960
|
+
*/
|
|
3961
|
+
exports.OutputsDelete = async function({libraryId, objectId, outputId}) {
|
|
3962
|
+
ValidateObject(objectId);
|
|
3963
|
+
ValidatePresence("outputId", outputId);
|
|
2006
3964
|
|
|
2007
|
-
if(
|
|
2008
|
-
|
|
2009
|
-
} else if(watermarkArgCount > 1) {
|
|
2010
|
-
throw Error("Only one watermark is allowed");
|
|
3965
|
+
if(!libraryId) {
|
|
3966
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
2011
3967
|
}
|
|
2012
3968
|
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
} else if(imageWatermark) {
|
|
2016
|
-
objectMetadata.image_watermark = imageWatermark;
|
|
2017
|
-
} else if(forensicWatermark) {
|
|
2018
|
-
objectMetadata.forensic_watermark = forensicWatermark;
|
|
2019
|
-
}
|
|
3969
|
+
// Route to any live egress node
|
|
3970
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
2020
3971
|
|
|
2021
|
-
|
|
2022
|
-
libraryId,
|
|
2023
|
-
objectId,
|
|
2024
|
-
writeToken,
|
|
2025
|
-
metadataSubtree: metadataPath,
|
|
2026
|
-
metadata: objectMetadata
|
|
2027
|
-
});
|
|
3972
|
+
try {
|
|
3973
|
+
const {writeToken} = await this.EditContentObject({libraryId, objectId});
|
|
2028
3974
|
|
|
2029
|
-
|
|
2030
|
-
await this.ReplaceMetadata({
|
|
3975
|
+
const result = await this.CallBitcodeMethod({
|
|
2031
3976
|
libraryId,
|
|
2032
3977
|
objectId,
|
|
2033
|
-
writeToken
|
|
2034
|
-
|
|
2035
|
-
|
|
3978
|
+
writeToken,
|
|
3979
|
+
method: UrlJoin("live", "outputs", outputId),
|
|
3980
|
+
verb: "DELETE",
|
|
3981
|
+
constant: false,
|
|
2036
3982
|
});
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
const response = {
|
|
2040
|
-
"imageWatermark": objectMetadata.image_watermark,
|
|
2041
|
-
"textWatermark": objectMetadata.simple_watermark,
|
|
2042
|
-
"forensicWatermark": objectMetadata.forensic_watermark
|
|
2043
|
-
};
|
|
2044
3983
|
|
|
2045
|
-
|
|
2046
|
-
const finalizeResponse = await this.FinalizeContentObject({
|
|
3984
|
+
await this.FinalizeContentObject({
|
|
2047
3985
|
libraryId,
|
|
2048
3986
|
objectId,
|
|
2049
3987
|
writeToken,
|
|
2050
|
-
commitMessage: "
|
|
3988
|
+
commitMessage: "Remove output"
|
|
2051
3989
|
});
|
|
2052
3990
|
|
|
2053
|
-
|
|
3991
|
+
return result;
|
|
3992
|
+
} finally {
|
|
3993
|
+
restore();
|
|
2054
3994
|
}
|
|
2055
|
-
|
|
2056
|
-
return response;
|
|
2057
3995
|
};
|
|
2058
3996
|
|
|
2059
3997
|
/**
|
|
2060
|
-
*
|
|
3998
|
+
* Delete multiple live outputs in a single operation.
|
|
2061
3999
|
*
|
|
2062
4000
|
* @methodGroup Live Stream
|
|
2063
4001
|
* @namedParams
|
|
2064
|
-
* @param {string=}
|
|
2065
|
-
* @param {string
|
|
2066
|
-
* @param {string
|
|
2067
|
-
* @param {Array<number>=} samples - list of percentages (0.0 - <1.0) used for sampling the content part list, up to 3
|
|
2068
|
-
* @param {string=} authorizationToken - Additional authorization token for this request
|
|
4002
|
+
* @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
|
|
4003
|
+
* @param {string} objectId - Object ID of the output settings object
|
|
4004
|
+
* @param {Array<string>} outputs - List of output IDs to delete
|
|
2069
4005
|
*
|
|
2070
|
-
* @returns {Promise<Object>} - Response
|
|
4006
|
+
* @returns {Promise<Object>} - Response from the delete call
|
|
2071
4007
|
*/
|
|
2072
|
-
exports.
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
4008
|
+
exports.OutputsDeleteBatch = async function({libraryId, objectId, outputs}) {
|
|
4009
|
+
ValidateObject(objectId);
|
|
4010
|
+
ValidatePresence("outputs", outputs);
|
|
4011
|
+
|
|
4012
|
+
if(!libraryId) {
|
|
4013
|
+
libraryId = await this.ContentObjectLibraryId({objectId});
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
const {restore} = await RouteToLiveEgress({client: this});
|
|
4017
|
+
|
|
4018
|
+
try {
|
|
4019
|
+
const {writeToken} = await this.EditContentObject({libraryId, objectId});
|
|
4020
|
+
|
|
4021
|
+
const result = await this.CallBitcodeMethod({
|
|
4022
|
+
libraryId,
|
|
4023
|
+
objectId,
|
|
4024
|
+
writeToken,
|
|
4025
|
+
method: UrlJoin("live", "outputs", outputId),
|
|
4026
|
+
verb: "DELETE",
|
|
4027
|
+
constant: false,
|
|
4028
|
+
});
|
|
4029
|
+
|
|
4030
|
+
await this.FinalizeContentObject({
|
|
4031
|
+
libraryId,
|
|
4032
|
+
objectId,
|
|
4033
|
+
writeToken,
|
|
4034
|
+
commitMessage: "Remove outputs (batch)"
|
|
4035
|
+
});
|
|
4036
|
+
|
|
4037
|
+
return result;
|
|
4038
|
+
} finally {
|
|
4039
|
+
restore();
|
|
4040
|
+
}
|
|
2082
4041
|
};
|