@eluvio/elv-client-js 4.2.16 → 4.2.17

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.
Files changed (32) hide show
  1. package/dist/ElvClient-min.js +1 -1
  2. package/dist/ElvClient-node-min.js +1 -1
  3. package/dist/ElvFrameClient-min.js +1 -1
  4. package/dist/ElvPermissionsClient-min.js +1 -1
  5. package/dist/ElvWalletClient-min.js +1 -1
  6. package/dist/ElvWalletClient-node-min.js +1 -1
  7. package/dist/src/AuthorizationClient.js +2 -1
  8. package/dist/src/ContentObjectAudit.js +2 -1
  9. package/dist/src/ContentObjectVerification.js +281 -0
  10. package/dist/src/ElvClient.js +8 -9
  11. package/dist/src/FrameClient.js +1 -1
  12. package/dist/src/HttpClient.js +83 -47
  13. package/dist/src/NetworkUrls.js +8 -0
  14. package/dist/src/abr_profiles/abr_profile_live_drm.js +0 -10
  15. package/dist/src/client/ContentAccess.js +76 -85
  16. package/dist/src/client/LiveConf.js +170 -84
  17. package/dist/src/client/LiveStream.js +5205 -2118
  18. package/dist/src/live_recording_config_profiles/live_recording_config_default.js +45 -0
  19. package/package.json +3 -2
  20. package/src/FrameClient.js +23 -2
  21. package/src/abr_profiles/abr_profile_live_drm.js +0 -10
  22. package/src/client/ContentAccess.js +1 -2
  23. package/src/client/LiveConf.js +149 -65
  24. package/src/client/LiveStream.js +2592 -654
  25. package/src/live_recording_config_profiles/live_recording_config_default.js +54 -0
  26. package/src/live_recording_config_profiles/live_stream_profile_full.json +143 -0
  27. package/testScripts/StreamUpdateLinks.js +95 -0
  28. package/utilities/LiveOutputs.js +149 -0
  29. package/utilities/StreamCreate.js +53 -0
  30. package/utilities/lib/helpers.js +5 -1
  31. package/utilities/tests/mocks/ElvClient.mock.js +9 -1
  32. package/utilities/tests/unit/StreamCreate.test.js +39 -0
@@ -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.library_id = libraryId;
368
- status.object_id = objectId;
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.reference_url = mainMeta.live_recording_config.reference_url;
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.fabric_api = fabURI;
406
- status.url = mainMeta.live_recording.recording_config.recording_params.origin_url;
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.edge_write_token = edgeWriteToken;
417
- status.stream_id = edgeWriteToken; // By convention the stream ID is its write token
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.edge_meta_size = JSON.stringify(edgeMeta || "").length;
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.recording_period_sequence = recordings.recording_sequence;
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
- let recording_period = {
468
- activation_time_epoch_sec: period.recording_start_time_epoch_sec,
469
- start_time_epoch_sec: period.start_time_epoch_sec,
470
- start_time_text: new Date(period.start_time_epoch_sec * 1000).toLocaleString(),
471
- end_time_epoch_sec: period.end_time_epoch_sec,
472
- end_time_text: period.end_time_epoch_sec === 0 ? null : new Date(period.end_time_epoch_sec * 1000).toLocaleString(),
473
- video_parts: videoFinalizedParts,
474
- video_last_part_finalized_epoch_sec: videoLastFinalizationTimeEpochSec,
475
- video_since_last_finalize_sec : sinceLastFinalize
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.recording_period = recording_period;
1036
+ status.recordingPeriod = recordingPeriod;
478
1037
 
479
- status.lro_status_url = await this.FabricUrl({
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 = recording_period.start_time_epoch_sec + insertions[i].insertion_time;
1050
+ let insertionTimeSinceEpoch = recordingPeriod.startTimeEpochSec + insertions[i].insertion_time;
492
1051
  status.insertions[i] = {
493
- insertion_time_since_start: insertions[i].insertion_time,
494
- insertion_time: new Date(insertionTimeSinceEpoch * 1000).toISOString(),
495
- insertion_time_local: new Date(insertionTimeSinceEpoch * 1000).toLocaleString(),
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.recording_params = edgeMeta.live_recording.recording_config.recording_params;
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.lro_status_url)
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.recording_status = lroStatus.custom.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 playout_urls = {};
533
- let playout_options = await this.PlayoutOptions({
534
- objectId
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
- playout_urls.hls_clear = await this.FabricUrl({
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
- playout_urls.hls_aes128 = await this.FabricUrl({
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
- playout_urls.hls_sample_aes = await this.FabricUrl({
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
- let embed_url = `https://embed.v3.contentfabric.io/?net=${embed_net}&p&ct=h&oid=${objectId}&mt=lv&ath=${token}`;
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.playout_urls = playout_urls;
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.StreamCreate = async function({name, start=false}) {
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.object_id;
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
- object_id: objectId,
1258
+ objectId: objectId,
694
1259
  hash: objectHash,
695
- library_id: libraryId,
696
- stream_id: edgeToken,
697
- edge_write_token: edgeToken,
698
- fabric_api: fabURI,
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.library_id,
739
- objectId: status.object_id,
740
- writeToken: status.edge_write_token,
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", "edge_write_token", status.edge_write_token);
1334
+ console.log("STARTING", "edgeWriteToken", status.edgeWriteToken);
770
1335
 
771
1336
  try {
772
1337
  await this.CallBitcodeMethod({
773
- libraryId: status.library_id,
774
- objectId: status.object_id,
775
- writeToken: status.edge_write_token,
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.StreamStopSession = async function({name}) {
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
- const status = await this.StreamStatus({name});
1413
+ let status = await this.StreamStatus({name});
849
1414
 
850
1415
  if(status.state !== "stopped") {
851
- return {
852
- state: status.state,
853
- error: "The stream must be stopped before terminating"
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
- let typeAbrMaster;
933
- let typeLiveStream;
934
-
935
- // Fetch Title and Live Stream content types from tenant meta
936
- const tenantContractId = await this.userProfileClient.TenantContractId();
937
- const {live_stream, title} = await this.ContentObjectMetadata({
938
- libraryId: tenantContractId.replace("iten", "ilib"),
939
- objectId: tenantContractId.replace("iten", "iq__"),
940
- metadataSubtree: "public/content_types",
941
- select: [
942
- "live_stream",
943
- "title"
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
- if(live_stream) {
948
- typeLiveStream = live_stream;
949
- }
1516
+ if(live_stream) {
1517
+ typeLiveStream = live_stream;
1518
+ }
950
1519
 
951
- if(title) {
952
- typeAbrMaster = title;
953
- }
1520
+ if(title) {
1521
+ typeAbrMaster = title;
1522
+ }
954
1523
 
955
- if(typeAbrMaster === undefined || typeLiveStream === undefined) {
956
- console.log("ERROR - unable to find content types", "ABR Master", typeAbrMaster, "Live Stream", typeLiveStream);
957
- return {};
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
- const res = await this.StreamSetOfferingAndDRM({
961
- name,
962
- typeAbrMaster,
963
- typeLiveStream,
964
- drm,
965
- format,
966
- writeToken,
967
- finalize
968
- });
1529
+ res = await this.StreamSetOfferingAndDRM({
1530
+ name,
1531
+ typeAbrMaster,
1532
+ typeLiveStream,
1533
+ drm,
1534
+ format,
1535
+ writeToken,
1536
+ finalize
1537
+ });
969
1538
 
970
- return res;
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
- if(status.state != "uninitialized" && status.state != "inactive" && status.state != "stopped") {
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.object_id;
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 i = 0; i < formats.length; i++) {
1034
- if(formats[i] === "hls-clear") {
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(formats[i] === "dash-clear") {
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[formats[i]] = abrProfile.playout_formats[formats[i]];
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
- let fabURI = mainMeta.live_recording.fabric_config.ingress_node_api;
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.live_recording.recording_config.recording_params.origin_url;
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
- object_id: objectId,
1722
+ objectId: objectId,
1131
1723
  state: "initialized"
1132
1724
  };
1133
1725
  } catch(error) {
@@ -1335,288 +1927,921 @@ exports.StreamInsertion = async function({name, insertionTime, sinceStart=false,
1335
1927
  };
1336
1928
 
1337
1929
  /**
1338
- * Configure the stream based on built-in logic and optional custom settings.
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
- * Custom settings format:
1341
- * {
1342
- * "audio" {
1343
- * "1" : { // This is the stream index
1344
- * "tags" : "language: english",
1345
- * "codec" : "aac",
1346
- * "bitrate": 204000,
1347
- * "record": true,
1348
- * "recording_bitrate" : 192000,
1349
- * "recording_channels" : 2,
1350
- * "playout": bool
1351
- * "playout_label": "English (Stereo)"
1352
- * },
1353
- * "3": {
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} name - Object ID or name of the live stream object
1362
- * @param {Object=} customSettings - Additional options to customize configuration settings
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
- * @return {Promise<Object>} - The status response for the stream
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
- let libraryId = await this.ContentObjectLibraryId({objectId});
1382
- status.library_id = libraryId;
1383
- status.object_id = objectId;
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
- let probe = probeMetadata;
1992
+ if(!profileSlug) {
1993
+ profileSlug = slugify(profileName);
1994
+ }
1386
1995
 
1387
- let mainMeta = await this.ContentObjectMetadata({
1388
- libraryId: libraryId,
1389
- objectId: objectId
1390
- });
1996
+ const {siteObjectId, siteLibraryId} = await this.StreamSiteSettings();
1391
1997
 
1392
- let userConfig = mainMeta.live_recording_config;
1393
- status.user_config = userConfig;
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
- // Get node URI from user config
1396
- const parsedName = userConfig.url
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
- // Probe the stream
1420
- probe = {};
1421
- try {
1422
- let probeUrl = await this.Rep({
1423
- libraryId,
1424
- objectId,
1425
- rep: "probe"
1426
- });
2009
+ return profileData;
2010
+ };
1427
2011
 
1428
- probe = await this.utils.ResponseToJson(
1429
- await HttpClient.Fetch(probeUrl, {
1430
- body: JSON.stringify({
1431
- "filename": streamUrl.href,
1432
- "listen": true
1433
- }),
1434
- method: "POST"
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
- if(probe.errors) {
1439
- throw probe.errors[0];
1440
- }
1441
- } catch(error) {
1442
- if(error.code === "ETIMEDOUT") {
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
- probe.format.filename = streamUrl.href;
2039
+ if(!writeToken) {
2040
+ ({writeToken} = await this.EditContentObject({
2041
+ libraryId,
2042
+ objectId
2043
+ }));
1450
2044
  }
1451
2045
 
1452
- // Create live recording config
1453
- let lc = new LiveConf(probe, node.id, endpoint, false, false, true);
2046
+ if(profileMetadata) {
2047
+ const defaultName = `Profile-${new Date().toISOString().slice(0, 10)}`;
2048
+ profileMetadata.last_updated = new Date().toISOString();
1454
2049
 
1455
- const liveRecordingConfig = lc.generateLiveConf({
1456
- customSettings
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
- // Store live recording config into the stream object
1460
- if(!writeToken) {
1461
- let e = await this.EditContentObject({
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: objectId
2065
+ objectId,
2066
+ writeToken,
2067
+ fileInfo
1464
2068
  });
1465
- writeToken = e.write_token;
1466
2069
  }
1467
2070
 
1468
- await this.ReplaceMetadata({
1469
- libraryId,
1470
- objectId,
1471
- writeToken,
1472
- metadataSubtree: "live_recording",
1473
- metadata: liveRecordingConfig.live_recording
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.ReplaceMetadata({
2081
+ await this.CreateLinks({
1477
2082
  libraryId,
1478
2083
  objectId,
1479
2084
  writeToken,
1480
- metadataSubtree: "live_recording_config/probe_info",
1481
- metadata: probe
2085
+ links
1482
2086
  });
1483
2087
 
1484
2088
  if(finalize) {
1485
- status.fin = await this.FinalizeContentObject({
2089
+ await this.FinalizeContentObject({
1486
2090
  libraryId,
1487
2091
  objectId,
1488
2092
  writeToken,
1489
- commitMessage: "Apply live stream configuration"
2093
+ commitMessage: "Add live recording config profile"
1490
2094
  });
1491
2095
  }
1492
-
1493
- return status;
1494
2096
  };
1495
2097
 
1496
2098
  /**
1497
- * List the pre-allocated URLs for a site
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=} siteId - ID of the live stream site object
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
- * @return {Promise<Object>} - The list of stream URLs
2109
+ * @returns {Promise<void>}
1504
2110
  */
1505
- exports.StreamListUrls = async function({siteId}={}) {
2111
+ exports.StreamAssignProfile = async function({
2112
+ profileSlug,
2113
+ streamObjectId,
2114
+ writeToken,
2115
+ finalize=true
2116
+ }) {
1506
2117
  try {
1507
- const STATUS_MAP = {
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
- siteId = await this.ContentObjectMetadata({
1525
- libraryId: tenantContractId.replace("iten", "ilib"),
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 streamMetadata = await this.ContentObjectMetadata({
1532
- libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
1533
- objectId: siteId,
1534
- metadataSubtree: "public/asset_metadata/live_streams",
1535
- resolveLinks: true,
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
- if(stream && stream["."] && stream["."].source) {
1549
- versionHash = stream["."].source;
1550
- }
2130
+ if(!profileStreams.includes(streamObjectId)) {
2131
+ profileStreams.push(streamObjectId);
1551
2132
 
1552
- if(versionHash) {
1553
- const objectId = this.utils.DecodeVersionHash(versionHash).objectId;
1554
- const libraryId = await this.ContentObjectLibraryId({objectId});
2133
+ if(!writeToken) {
2134
+ ({writeToken} = await this.EditContentObject({
2135
+ libraryId,
2136
+ objectId
2137
+ }))
2138
+ }
1555
2139
 
1556
- const status = await this.StreamStatus({
1557
- name: objectId
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
- const streamMeta = await this.ContentObjectMetadata({
1561
- objectId,
1562
- libraryId,
1563
- select: [
1564
- "live_recording_config/reference_url",
1565
- // live_recording_config/url is the old path
1566
- "live_recording_config/url"
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
- const url = streamMeta.live_recording_config.reference_url || streamMeta.live_recording_config.url;
1571
- const isActive = [STATUS_MAP.STARTING, STATUS_MAP.RUNNING, STATUS_MAP.STALLED, STATUS_MAP.STOPPED].includes(status.state);
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
- if(url && isActive) {
1574
- activeUrlMap[url] = true;
1575
- }
1576
- }
1577
- }
1578
- );
2185
+ if(!objectId || !libraryId) {
2186
+ throw new Error("Site object must be configured first.")
2187
+ }
1579
2188
 
1580
- const streamUrlStatus = {};
2189
+ let profileStreams = await this.ContentObjectMetadata({
2190
+ libraryId,
2191
+ objectId,
2192
+ metadataSubtree: `public/asset_metadata/profile_streams/${profileSlug}`
2193
+ }) || [];
1581
2194
 
1582
- const streamUrls = await this.ContentObjectMetadata({
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(!streamUrls) {
1591
- throw Error("No pre-allocated URLs configured");
2197
+ if(!writeToken) {
2198
+ ({writeToken} = await this.EditContentObject({
2199
+ libraryId,
2200
+ objectId
2201
+ }));
1592
2202
  }
1593
2203
 
1594
- Object.keys(streamUrls || {}).forEach(protocol => {
1595
- streamUrlStatus[protocol] = streamUrls[protocol].map(url => {
1596
- return {
1597
- url,
1598
- active: activeUrlMap[url] || false
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
- return streamUrlStatus;
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
- * Copy a portion of a live stream recording into a standard VoD object using the zero-copy content fabric API
1611
- *
1612
- * Limitations:
1613
- * - currently requires the target object to be pre-created and have content encryption keys (CAPS)
1614
- * - for audio and video to be sync'd, the live stream needs to have the beginning of the desired recording period
1615
- * - 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
1616
- * - 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
1617
- * to allow running the live-to-vod command before the beginning of the recording expires.
1618
- * - startTime and endTime are not currently implemented by this method
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
+ // Load the base config profile and merge with overrides
2269
+ const overrides = await this.ContentObjectMetadata({
2270
+ libraryId,
2271
+ objectId,
2272
+ writeToken: streamWriteToken,
2273
+ metadataSubtree: "live_recording_overrides"
2274
+ }) || {};
2275
+
2276
+ const currentConfig = await this.ContentObjectMetadata({
2277
+ libraryId,
2278
+ objectId,
2279
+ writeToken: streamWriteToken,
2280
+ metadataSubtree: "live_recording_config"
2281
+ }) || {};
2282
+
2283
+ // Preserve stream-specific fields (e.g. url) from the current config, then apply profile, then overrides
2284
+ const config = R.mergeDeepRight(R.mergeDeepRight(currentConfig, profile), overrides);
2285
+
2286
+ const currentProfileName = await this.ContentObjectMetadata({
2287
+ libraryId,
2288
+ objectId,
2289
+ writeToken: streamWriteToken,
2290
+ metadataSubtree: "live_recording_config/name"
2291
+ });
2292
+ const currentProfileSlug = slugify(currentProfileName);
2293
+
2294
+ await this.StreamUpdateConfig({
2295
+ libraryId,
2296
+ objectId,
2297
+ writeToken: streamWriteToken,
2298
+ finalize: false,
2299
+ liveRecordingConfig: config
2300
+ });
2301
+
2302
+ if(!profileSlug) {
2303
+ profileSlug = slugify(profile.name);
2304
+ }
2305
+
2306
+ const {siteObjectId, siteLibraryId} = await this.StreamSiteSettings({resolveIncludeSource: false, resolveLinks: false});
2307
+
2308
+ if(!siteWriteToken) {
2309
+ ({writeToken: siteWriteToken} = await this.EditContentObject({libraryId: siteLibraryId, objectId: siteObjectId}));
2310
+ }
2311
+
2312
+ if(currentProfileSlug && currentProfileSlug !== profileSlug) {
2313
+ await this.StreamUnassignProfile({
2314
+ profileSlug: currentProfileSlug,
2315
+ streamObjectId: objectId,
2316
+ writeToken: siteWriteToken,
2317
+ finalize: false
2318
+ })
2319
+ }
2320
+
2321
+ // Update profile update timestamp
2322
+ await this.ReplaceMetadata({
2323
+ libraryId,
2324
+ objectId,
2325
+ writeToken: streamWriteToken,
2326
+ metadataSubtree: "public/asset_metadata/profile_last_updated",
2327
+ metadata: new Date().toISOString()
2328
+ });
2329
+
2330
+ await this.StreamAssignProfile({
2331
+ profileSlug,
2332
+ streamObjectId: objectId,
2333
+ writeToken: siteWriteToken,
2334
+ finalize: false
2335
+ });
2336
+
2337
+ if(finalize) {
2338
+ await this.FinalizeContentObject({
2339
+ libraryId,
2340
+ objectId,
2341
+ writeToken: streamWriteToken,
2342
+ commitMessage: "Update profile"
2343
+ });
2344
+
2345
+ await this.FinalizeContentObject({
2346
+ libraryId: siteLibraryId,
2347
+ objectId: siteObjectId,
2348
+ writeToken: siteWriteToken,
2349
+ commitMessage: "Update profile streams"
2350
+ });
2351
+ }
2352
+
2353
+ if(!finalize) {
2354
+ return {streamWriteToken, siteWriteToken};
2355
+ } else {
2356
+ return {config};
2357
+ }
2358
+ };
2359
+
2360
+ /**
2361
+ * Update the live recording configuration of a stream object.
2362
+ *
2363
+ * @methodGroup Live Stream
2364
+ * @namedParams
2365
+ * @param {string} libraryId - Library ID of the stream object
2366
+ * @param {string} objectId - Object ID of the stream
2367
+ * @param {string} commitMessage - Message to include about this commit
2368
+ * @param {string=} writeToken - Write token for the stream object. If not provided, a new edit will be opened.
2369
+ * @param {LiveRecordingConfig} liveRecordingConfig - The live recording configuration to write
2370
+ * @param {Object=} overrideSettings - Partial LiveRecordingConfig deep-merged over liveRecordingConfig
2371
+ * @param {boolean=} finalize - If enabled, the stream object will be finalized after the update (default: true)
2372
+ *
2373
+ * @returns {Promise<{writeToken: string}|void>} - The write token if finalize is false, otherwise void
2374
+ */
2375
+ exports.StreamUpdateConfig = async function({
2376
+ libraryId,
2377
+ objectId,
2378
+ writeToken,
2379
+ liveRecordingConfig,
2380
+ overrideSettings,
2381
+ commitMessage,
2382
+ finalize=true
2383
+ }) {
2384
+ if(!writeToken) {
2385
+ ({writeToken} = await this.EditContentObject({
2386
+ libraryId,
2387
+ objectId
2388
+ }));
2389
+ }
2390
+
2391
+ if(!libraryId) {
2392
+ libraryId = await this.ContentObjectLibraryId({objectId});
2393
+ }
2394
+
2395
+ if(overrideSettings) {
2396
+ await this.ReplaceMetadata({
2397
+ libraryId,
2398
+ objectId,
2399
+ writeToken,
2400
+ metadataSubtree: "live_recording_overrides",
2401
+ metadata: overrideSettings
2402
+ });
2403
+
2404
+ liveRecordingConfig = R.mergeDeepRight(liveRecordingConfig, overrideSettings);
2405
+ }
2406
+
2407
+ await this.ReplaceMetadata({
2408
+ libraryId,
2409
+ objectId,
2410
+ writeToken,
2411
+ metadataSubtree: "live_recording_config",
2412
+ metadata: liveRecordingConfig
2413
+ });
2414
+
2415
+ if(finalize) {
2416
+ await this.FinalizeContentObject({
2417
+ libraryId,
2418
+ objectId,
2419
+ writeToken,
2420
+ commitMessage: commitMessage ?? "Update stream config"
2421
+ });
2422
+ }
2423
+
2424
+ if(!finalize) {
2425
+ return {writeToken};
2426
+ }
2427
+ };
2428
+
2429
+ /**
2430
+ * @typedef {Object} InputStreamInfo
2431
+ * @property {Object=} input_stream_info - Simplified probe information for the input stream
2432
+ * @property {Object=} input_stream_info.format - Format information
2433
+ * @property {string=} input_stream_info.format.format_name - Format name (e.g., "mpegts")
2434
+ * @property {string=} input_stream_info.format.filename - Stream URL
2435
+ * @property {Array<Object>=} input_stream_info.streams - Array of stream information
2436
+ * @property {string=} input_stream_info.streams[].codec_name - Codec name (e.g., "h264", "aac")
2437
+ * @property {string=} input_stream_info.streams[].codec_type - Codec type ("video" or "audio")
2438
+ * @property {string=} input_stream_info.streams[].display_aspect_ratio - Display aspect ratio (e.g., "16/9")
2439
+ * @property {string=} input_stream_info.streams[].field_order - Field order (e.g., "progressive")
2440
+ * @property {string=} input_stream_info.streams[].frame_rate - Frame rate (e.g., "50")
2441
+ * @property {number=} input_stream_info.streams[].height - Video height in pixels
2442
+ * @property {number=} input_stream_info.streams[].width - Video width in pixels
2443
+ * @property {number=} input_stream_info.streams[].level - Codec level
2444
+ * @property {number=} input_stream_info.streams[].stream_id - Stream ID
2445
+ * @property {number=} input_stream_info.streams[].stream_index - Stream index
2446
+ * @property {number=} input_stream_info.streams[].channel_layout - Audio channel layout
2447
+ * @property {number=} input_stream_info.streams[].channels - Number of audio channels
2448
+ * @property {number=} input_stream_info.streams[].sample_rate - Audio sample rate
2449
+ */
2450
+
2451
+ /**
2452
+ * @typedef {Object} LiveRecordingConfig
2453
+ * @property {string=} name - Name of the profile
2454
+ *
2455
+ * @property {Object=} recording_config - Recording configuration settings
2456
+ * @property {number=} recording_config.part_ttl - Time-to-live for stream parts in seconds
2457
+ * @property {number=} recording_config.connection_timeout - Initial connection timeout when starting the stream, in seconds
2458
+ * @property {number=} recording_config.reconnect_timeout - Duration to listen after disconnect detection, in seconds
2459
+ * @property {boolean=} recording_config.copy_mpegts - Whether to copy MPEG-TS data
2460
+ * @property {Object=} recording_config.input_cfg - Input configuration settings
2461
+ * @property {boolean=} recording_config.input_cfg.bypass_libav_reader - Whether to bypass libav reader
2462
+ * @property {string=} recording_config.input_cfg.copy_mode - Copy mode setting: "" (empty), "none", "raw", or "remuxed"
2463
+ * @property {string=} recording_config.input_cfg.copy_packaging - Copy packaging mode: "raw_ts" or "rtp_ts"
2464
+ * @property {boolean=} recording_config.input_cfg.custom_read_loop_enabled - Legacy reader
2465
+ * @property {string=} recording_config.input_cfg.input_packaging - Input Packaging
2466
+ *
2467
+ * @property {Object=} playout_config - Playout configuration settings
2468
+ * @property {Object=} playout_config.image_watermark - Image watermark configuration
2469
+ * @property {string=} playout_config.image_watermark.align_h - Horizontal alignment (e.g., "left", "center", "right")
2470
+ * @property {string=} playout_config.image_watermark.align_v - Vertical alignment (e.g., "top", "middle", "bottom")
2471
+ * @property {string=} playout_config.image_watermark.image - Path to watermark image file
2472
+ * @property {boolean=} playout_config.image_watermark.wm_enabled - Whether the image watermark is enabled
2473
+ * @property {Object=} playout_config.simple_watermark - Simple text watermark configuration
2474
+ * @property {string=} playout_config.simple_watermark.font_color - Font color (e.g., "white@0.5")
2475
+ * @property {number=} playout_config.simple_watermark.font_relative_height - Font size relative to video height
2476
+ * @property {boolean=} playout_config.simple_watermark.shadow - Whether to add shadow to text
2477
+ * @property {string=} playout_config.simple_watermark.shadow_color - Shadow color (e.g., "black@0.5")
2478
+ * @property {string=} playout_config.simple_watermark.template - Watermark text template
2479
+ * @property {string=} playout_config.simple_watermark.x - Horizontal position expression
2480
+ * @property {string=} playout_config.simple_watermark.y - Vertical position expression
2481
+ * @property {boolean=} playout_config.dvr - Whether to enable DVR functionality
2482
+ * TODO: update possible drm types
2483
+ * TODO: update possible playout formats
2484
+ * @property {Array<string>=} playout_config.playout_formats - List of playout format names (e.g., "dash-widevine", "hls-widevine")
2485
+ * @property {Object=} playout_config.ladder_specs - Encoding ladder specifications
2486
+ * @property {Array<Object>=} playout_config.ladder_specs.audio - Audio encoding ladder
2487
+ * @property {number=} playout_config.ladder_specs.audio[].bit_rate - Audio bitrate
2488
+ * @property {number=} playout_config.ladder_specs.audio[].channels - Number of audio channels
2489
+ * @property {string=} playout_config.ladder_specs.audio[].codecs - Audio codec identifier
2490
+ * @property {Array<Object>=} playout_config.ladder_specs.video - Video encoding ladder
2491
+ * @property {number=} playout_config.ladder_specs.video[].bit_rate - Video bitrate
2492
+ * @property {string=} playout_config.ladder_specs.video[].codecs - Video codec identifier
2493
+ * @property {number=} playout_config.ladder_specs.video[].height - Video height in pixels
2494
+ * @property {number=} playout_config.ladder_specs.video[].width - Video width in pixels
2495
+ *
2496
+ * @property {Object=} recording_stream_config - Stream recording configuration
2497
+ * @property {Object=} recording_stream_config.audio - Audio stream recording configuration indexed by stream number
2498
+ * @property {number=} recording_stream_config.audio[].bitrate - Stream bitrate
2499
+ * @property {string=} recording_stream_config.audio[].codec - Audio codec (e.g., "aac")
2500
+ * @property {boolean=} recording_stream_config.audio[].playout - Whether to include this stream in playout
2501
+ * @property {string=} recording_stream_config.audio[].playout_label - Label for playout (e.g., "Audio 1")
2502
+ * @property {boolean=} recording_stream_config.audio[].record - Whether to record this audio stream
2503
+ * @property {number=} recording_stream_config.audio[].recording_bitrate - Recording bitrate
2504
+ * @property {number=} recording_stream_config.audio[].recording_channels - Number of recording channels
2505
+ *
2506
+ * @property {InputStreamInfo=} input_stream_info - Simplified probe information for the input stream
2507
+ *
2508
+ * @property {Object=} recording_params - Advanced recording parameters
2509
+ * @property {Object=} recording_params.xc_params - Transcoding parameters
2510
+ * @property {number=} recording_params.xc_params.audio_bitrate - Audio bitrate for encoding
2511
+ * @property {Object=} recording_params.xc_params.audio_index - Audio stream index mapping (indexed by output stream number)
2512
+ * @property {number=} recording_params.xc_params.audio_seg_duration_ts - Audio segment duration in time scale units
2513
+ * @property {number=} recording_params.xc_params.connection_timeout - Connection timeout in seconds
2514
+ * @property {boolean=} recording_params.xc_params.copy_mpegts - Whether to copy MPEG-TS data
2515
+ * @property {string=} recording_params.xc_params.ecodec2 - Audio encoder codec (e.g., "aac")
2516
+ * @property {number=} recording_params.xc_params.enc_height - Encoding height in pixels
2517
+ * @property {number=} recording_params.xc_params.enc_width - Encoding width in pixels
2518
+ * @property {string=} recording_params.xc_params.filter_descriptor - FFmpeg filter descriptor string
2519
+ * @property {number=} recording_params.xc_params.force_keyint - Force keyframe interval
2520
+ * @property {string=} recording_params.xc_params.format - Output format (e.g., "fmp4-segment")
2521
+ * @property {boolean=} recording_params.xc_params.listen - Whether to listen for incoming stream
2522
+ * @property {number=} recording_params.xc_params.n_audio - Number of audio streams
2523
+ * @property {string=} recording_params.xc_params.preset - Encoding preset (e.g., "faster", "medium", "slow")
2524
+ * @property {number=} recording_params.xc_params.sample_rate - Audio sample rate in Hz
2525
+ * @property {string=} recording_params.xc_params.seg_duration - Segment duration in seconds (as string)
2526
+ * @property {boolean=} recording_params.xc_params.skip_decoding - Whether to skip decoding
2527
+ * @property {string=} recording_params.xc_params.start_segment_str - Starting segment number (as string)
2528
+ * @property {number=} recording_params.xc_params.stream_id - Stream ID (-1 for auto)
2529
+ * @property {number=} recording_params.xc_params.sync_audio_to_stream_id - Stream ID to sync audio to
2530
+ * @property {number=} recording_params.xc_params.video_bitrate - Video bitrate for encoding
2531
+ * @property {number=} recording_params.xc_params.video_frame_duration_ts - Video frame duration in time scale units (null for auto)
2532
+ * @property {number=} recording_params.xc_params.video_seg_duration_ts - Video segment duration in time scale units
2533
+ * @property {string=} recording_params.xc_params.video_time_base - Video time base (null for auto)
2534
+ * @property {number=} recording_params.xc_params.xc_type - Transcoding type identifier
2535
+ *
2536
+ * @property {Object=} probe_info - Full probe information (stored for historical/debugging purposes, only in live_recording_config)
2537
+ *
2538
+ */
2539
+
2540
+ /**
2541
+ * Configure the stream based on built-in logic and optional custom settings.
2542
+ *
2543
+ * @methodGroup Live Stream
2544
+ * @namedParams
2545
+ * @param {string} name - Object ID or name of the live stream object
2546
+ * @param {LiveRecordingConfig=} liveRecordingConfig - Configuration profile for the live stream including recording, playout, and transcoding settings
2547
+ * @param {InputStreamInfo=} inputStreamInfo - Simplified probe metadata
2548
+ * @param {string=} writeToken - Write token of the draft
2549
+ * @param {boolean=} finalize - If enabled, target object will be finalized after configuring
2550
+ *
2551
+ * @return {Promise<Object>} - The status response for the stream
2552
+ *
2553
+ */
2554
+ exports.StreamConfig = async function({
2555
+ name,
2556
+ liveRecordingConfig,
2557
+ inputStreamInfo,
2558
+ writeToken,
2559
+ finalize=true
2560
+ }) {
2561
+ const objectId = name;
2562
+ let probe = inputStreamInfo || liveRecordingConfig?.input_stream_info;
2563
+
2564
+ const currentStatus = await this.StreamStatus({name, writeToken});
2565
+
2566
+ if(!["uninitialized", "inactive", "unconfigured"].includes(currentStatus.state)) {
2567
+ return {
2568
+ state: currentStatus.state,
2569
+ error: "Stream still active - must deactivate first"
2570
+ };
2571
+ }
2572
+
2573
+ const libraryId = await this.ContentObjectLibraryId({objectId});
2574
+
2575
+ const status = {
2576
+ name,
2577
+ libraryId: libraryId,
2578
+ objectId: objectId,
2579
+ }
2580
+
2581
+ const liveRecordingMeta = await this.ContentObjectMetadata({
2582
+ libraryId: libraryId,
2583
+ objectId,
2584
+ writeToken,
2585
+ metadataSubtree: "/live_recording"
2586
+ });
2587
+
2588
+ let liveRecordingConfigProfile;
2589
+ if(liveRecordingConfig && Object.keys(liveRecordingConfig || {}).length > 0) {
2590
+ // Extract values that may have been saved during Create but aren't being repeated in the Config step
2591
+ const savedConfigData = await this.ContentObjectMetadata({
2592
+ libraryId,
2593
+ objectId,
2594
+ writeToken,
2595
+ metadataSubtree: "/live_recording_config"
2596
+ });
2597
+
2598
+ liveRecordingConfigProfile = R.mergeDeepRight(savedConfigData ?? {}, liveRecordingConfig);
2599
+ } else {
2600
+ const lrcMeta = await this.ContentObjectMetadata({
2601
+ libraryId: libraryId,
2602
+ objectId,
2603
+ writeToken,
2604
+ metadataSubtree: "/live_recording_config",
2605
+ });
2606
+
2607
+ // Save liveRecordingConfig as saved profile or default
2608
+ liveRecordingConfigProfile = lrcMeta ?? LRCProfile;
2609
+ }
2610
+
2611
+ let nodeId = liveRecordingConfigProfile?.ingress_node_id;
2612
+
2613
+ status.userConfig = liveRecordingConfigProfile;
2614
+
2615
+ const streamData = {
2616
+ client: this
2617
+ };
2618
+
2619
+ if(nodeId) {
2620
+ streamData.nodeId = nodeId;
2621
+ streamData.nodeApi = liveRecordingConfigProfile?.url;
2622
+ } else {
2623
+ streamData.url = liveRecordingConfigProfile.url;
2624
+ }
2625
+
2626
+ // Get node URI from user config
2627
+ let node, endpoint, streamHref;
2628
+ try {
2629
+ ({node, endpoint, streamHref} = await GetNodeFromStreamData(streamData));
2630
+ status.node = node;
2631
+ nodeId = node.id;
2632
+ } catch(error) {
2633
+ throw error;
2634
+ }
2635
+
2636
+ // No stream data provided ; probe the stream for info
2637
+ if(!probe) {
2638
+ probe = await GetStreamProbe({
2639
+ client: this,
2640
+ libraryId,
2641
+ objectId,
2642
+ streamHref,
2643
+ endpoint
2644
+ });
2645
+ }
2646
+
2647
+ // Create live recording config
2648
+ const liveConf = new LiveConf({
2649
+ url: liveRecordingConfigProfile.url,
2650
+ probeData: probe,
2651
+ liveRecordingMeta,
2652
+ nodeId,
2653
+ nodeUrl: endpoint,
2654
+ includeAVSegDurations: false,
2655
+ overwriteOriginUrl: false,
2656
+ syncAudioToVideo: true
2657
+ });
2658
+
2659
+ const liveRecordingConfigMeta = liveConf.generateLiveConf({
2660
+ customSettings: {
2661
+ liveRecordingConfigProfile
2662
+ }
2663
+ });
2664
+
2665
+ // Store live recording config into the stream object
2666
+ if(!writeToken) {
2667
+ let e = await this.EditContentObject({
2668
+ libraryId,
2669
+ objectId: objectId
2670
+ });
2671
+ writeToken = e.write_token;
2672
+ }
2673
+
2674
+ if(["uninitialized", "unconfigured"].includes(currentStatus.state)) {
2675
+ const formats = liveRecordingConfigMeta?.live_recording.playout_config?.playout_formats;
2676
+
2677
+ await this.StreamInitialize({
2678
+ name: objectId,
2679
+ drm: (formats || []).some(el => !el.includes("clear")) ? true : false,
2680
+ format: formats ? formats?.join(",") : "",
2681
+ writeToken,
2682
+ finalize: false
2683
+ });
2684
+ }
2685
+
2686
+ const allowList = ["fabric_config", "playout_config", "recording_config", "url"];
2687
+ const filteredMeta = Object.fromEntries(
2688
+ Object.entries(liveRecordingConfigMeta.live_recording || {}).filter(([key]) => allowList.includes(key))
2689
+ );
2690
+
2691
+ await this.ReplaceMetadata({
2692
+ libraryId,
2693
+ objectId,
2694
+ writeToken,
2695
+ metadataSubtree: "live_recording",
2696
+ metadata: filteredMeta
2697
+ });
2698
+
2699
+ await this.ReplaceMetadata({
2700
+ libraryId,
2701
+ objectId,
2702
+ writeToken,
2703
+ metadataSubtree: "live_recording_config/probe_info",
2704
+ metadata: probe
2705
+ });
2706
+
2707
+ if(finalize) {
2708
+ status.fin = await this.FinalizeContentObject({
2709
+ libraryId,
2710
+ objectId,
2711
+ writeToken,
2712
+ commitMessage: "Apply live stream configuration"
2713
+ });
2714
+ }
2715
+
2716
+ return status;
2717
+ };
2718
+
2719
+ /**
2720
+ * List the pre-allocated URLs for a site
2721
+ *
2722
+ * @methodGroup Live Stream
2723
+ * @namedParams
2724
+ * @param {string=} siteId - ID of the live stream site object
2725
+ *
2726
+ * @return {Promise<Object>} - The list of stream URLs
2727
+ */
2728
+ exports.StreamListUrls = async function({siteId}={}) {
2729
+ try {
2730
+ const STATUS_MAP = {
2731
+ UNCONFIGURED: "unconfigured",
2732
+ UNINITIALIZED: "uninitialized",
2733
+ INACTIVE: "inactive",
2734
+ STOPPED: "stopped",
2735
+ STARTING: "starting",
2736
+ RUNNING: "running",
2737
+ STALLED: "stalled",
2738
+ };
2739
+
2740
+ if(!siteId) {
2741
+ const tenantContractId = await this.userProfileClient.TenantContractId();
2742
+
2743
+ if(!tenantContractId) {
2744
+ throw Error("No tenant contract ID configured");
2745
+ }
2746
+
2747
+ siteId = await this.ContentObjectMetadata({
2748
+ libraryId: tenantContractId.replace("iten", "ilib"),
2749
+ objectId: tenantContractId.replace("iten", "iq__"),
2750
+ metadataSubtree: "public/sites/live_streams",
2751
+ });
2752
+ }
2753
+
2754
+ const streamMetadata = await this.ContentObjectMetadata({
2755
+ libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
2756
+ objectId: siteId,
2757
+ metadataSubtree: "public/asset_metadata/live_streams",
2758
+ resolveLinks: true,
2759
+ resolveIgnoreErrors: true,
2760
+ resolveIncludeSource: true
2761
+ });
2762
+
2763
+ const activeUrlMap = {};
2764
+ await this.utils.LimitedMap(
2765
+ 10,
2766
+ Object.keys(streamMetadata || {}),
2767
+ async slug => {
2768
+ const stream = streamMetadata[slug];
2769
+ let versionHash;
2770
+
2771
+ if(stream && stream["."] && stream["."].source) {
2772
+ versionHash = stream["."].source;
2773
+ }
2774
+
2775
+ if(versionHash) {
2776
+ const objectId = this.utils.DecodeVersionHash(versionHash).objectId;
2777
+ const libraryId = await this.ContentObjectLibraryId({objectId});
2778
+
2779
+ const status = await this.StreamStatus({
2780
+ name: objectId
2781
+ });
2782
+
2783
+ const streamMeta = await this.ContentObjectMetadata({
2784
+ objectId,
2785
+ libraryId,
2786
+ select: [
2787
+ // live_recording_config/reference_url is an old path
2788
+ "live_recording_config/reference_url",
2789
+ "live_recording_config/ingress_node_api",
2790
+ // live_recording_config/url is the old path
2791
+ "live_recording_config/url"
2792
+ ]
2793
+ }) || {};
2794
+
2795
+ const url = streamMeta.live_recording_config?.ingress_node_api || streamMeta.live_recording_config?.reference_url || streamMeta.live_recording_config?.url;
2796
+ const isActive = [STATUS_MAP.STARTING, STATUS_MAP.RUNNING, STATUS_MAP.STALLED, STATUS_MAP.STOPPED].includes(status.state);
2797
+
2798
+ if(url && isActive) {
2799
+ activeUrlMap[url] = true;
2800
+ }
2801
+ }
2802
+ }
2803
+ );
2804
+
2805
+ const streamUrlStatus = {};
2806
+
2807
+ const streamUrls = await this.ContentObjectMetadata({
2808
+ libraryId: await this.ContentObjectLibraryId({objectId: siteId}),
2809
+ objectId: siteId,
2810
+ metadataSubtree: "/live_stream_urls",
2811
+ resolveLinks: true,
2812
+ resolveIgnoreErrors: true
2813
+ });
2814
+
2815
+ if(!streamUrls) {
2816
+ throw Error("No pre-allocated URLs configured");
2817
+ }
2818
+
2819
+ Object.keys(streamUrls || {}).forEach(protocol => {
2820
+ streamUrlStatus[protocol] = streamUrls[protocol].map(url => {
2821
+ return {
2822
+ url,
2823
+ active: activeUrlMap[url] || false
2824
+ };
2825
+ });
2826
+ });
2827
+
2828
+ return streamUrlStatus;
2829
+ } catch(error) {
2830
+ console.error(error);
2831
+ }
2832
+ };
2833
+
2834
+ /**
2835
+ * Copy a portion of a live stream recording into a standard VoD object using the zero-copy content fabric API
2836
+ *
2837
+ * Limitations:
2838
+ * - currently requires the target object to be pre-created and have content encryption keys (CAPS)
2839
+ * - for audio and video to be sync'd, the live stream needs to have the beginning of the desired recording period
2840
+ * - 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
2841
+ * - 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
2842
+ * to allow running the live-to-vod command before the beginning of the recording expires.
2843
+ * - startTime and endTime are not currently implemented by this method
2844
+ *
1620
2845
  *
1621
2846
  * @methodGroup Live Stream
1622
2847
  * @namedParams
@@ -1626,457 +2851,1170 @@ exports.StreamListUrls = async function({siteId}={}) {
1626
2851
  * @param {boolean=} finalize - If enabled, target object will be finalized after copy to vod operations
1627
2852
  * @param {number=} recordingPeriod - Determines which recording period to copy, which are 0-based. -1 copies the current (or last) period
1628
2853
  *
1629
- * @return {Promise<Object>} - The status response for the stream
2854
+ * @return {Promise<Object>} - The status response for the stream
2855
+ */
2856
+
2857
+ /*
2858
+ Example fabric API flow:
2859
+
2860
+ https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/live_to_vod/init -d @r1 -H "Authorization: Bearer $TOK"
2861
+
2862
+ {
2863
+ "live_qhash": "hq__5Zk1jSN8vNLUAXjQwMJV8F8J8ESXNvmVKkhaXySmGc1BXnJPG2FvvaXee4CXqvFHuGuU3fqLJc",
2864
+ "start_time": "",
2865
+ "end_time": "",
2866
+ "recording_period": -1,
2867
+ "streams": ["video", "audio"],
2868
+ "variant_key": "default"
2869
+ }
2870
+
2871
+ https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/init -H "Authorization: Bearer $TOK" -d @r2
2872
+
2873
+ {
2874
+
2875
+ "abr_profile": { ... },
2876
+ "offering_key": "default",
2877
+ "prod_master_hash": "tqw__HSQHBt7vYxWfCMPH5yXwKTfhdPcQ4Lcs9WUMUbTtnMbTZPTLo4BfJWPMGpoy1Dpv1wWQVtUtAtAr429TnVs",
2878
+ "variant_key": "default",
2879
+ "keep_other_streams": false
2880
+ }
2881
+
2882
+ 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"
2883
+
2884
+
2885
+ https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/offerings/default/finalize -d '{}' -H "Authorization: Bearer $TOK"
2886
+
2887
+ */
2888
+
2889
+ exports.StreamCopyToVod = async function({
2890
+ name,
2891
+ targetObjectId,
2892
+ eventId,
2893
+ streams=null,
2894
+ finalize=true,
2895
+ recordingPeriod=-1,
2896
+ startTime="",
2897
+ endTime=""
2898
+ }) {
2899
+ const objectId = name;
2900
+ const abrProfile = require("../abr_profiles/abr_profile_live_to_vod.js");
2901
+
2902
+ const status = await this.StreamStatus({name});
2903
+ const libraryId = status.libraryId;
2904
+
2905
+ this.Log(`Copying stream ${name} to target ${targetObjectId}`);
2906
+
2907
+ ValidateObject(targetObjectId);
2908
+
2909
+ const targetLibraryId = await this.ContentObjectLibraryId({objectId: targetObjectId});
2910
+
2911
+ // Validation - ensure target object has content encryption keys
2912
+ const kmsAddress = await this.authClient.KMSAddress({objectId: targetObjectId});
2913
+ const kmsCapId = `eluv.caps.ikms${this.utils.AddressToHash(kmsAddress)}`;
2914
+ const kmsCap = await this.ContentObjectMetadata({
2915
+ libraryId: targetLibraryId,
2916
+ objectId: targetObjectId,
2917
+ metadataSubtree: kmsCapId
2918
+ });
2919
+
2920
+ if(!kmsCap) {
2921
+ throw Error(`No content encryption key set for object ${targetObjectId}`);
2922
+ }
2923
+
2924
+ try {
2925
+ status.liveObjectId = objectId;
2926
+
2927
+ const liveHash = await this.LatestVersionHash({objectId, libraryId});
2928
+ status.liveHash = liveHash;
2929
+
2930
+ if(eventId) {
2931
+ // Retrieve start and end times for the event
2932
+ let event = await this.CueInfo({eventId, status});
2933
+ if(event.eventStart && event.eventEnd) {
2934
+ startTime = event.eventStart;
2935
+ endTime = event.eventEnd;
2936
+ }
2937
+ }
2938
+
2939
+ const {writeToken} = await this.EditContentObject({
2940
+ objectId: targetObjectId,
2941
+ libraryId: targetLibraryId
2942
+ });
2943
+
2944
+ status.targetObjectId = targetObjectId;
2945
+ status.targetLibraryId = targetLibraryId;
2946
+ status.targetWriteToken = writeToken;
2947
+
2948
+ this.Log("Process live source (takes around 20 sec per hour of content)");
2949
+
2950
+ await this.CallBitcodeMethod({
2951
+ libraryId: targetLibraryId,
2952
+ objectId: targetObjectId,
2953
+ writeToken,
2954
+ method: "/media/live_to_vod/init",
2955
+ body: {
2956
+ "live_qhash": liveHash,
2957
+ "start_time": startTime, // eg. "2023-10-03T02:09:02.00Z",
2958
+ "end_time": endTime, // eg. "2023-10-03T02:15:00.00Z",
2959
+ "streams": streams,
2960
+ "recording_period": recordingPeriod,
2961
+ "variant_key": "default"
2962
+ },
2963
+ constant: false,
2964
+ format: "text"
2965
+ });
2966
+
2967
+ const abrMezInitBody = {
2968
+ abr_profile: abrProfile,
2969
+ "offering_key": "default",
2970
+ "prod_master_hash": writeToken,
2971
+ "variant_key": "default",
2972
+ "keep_other_streams": false
2973
+ };
2974
+
2975
+ await this.CallBitcodeMethod({
2976
+ libraryId: targetLibraryId,
2977
+ objectId: targetObjectId,
2978
+ writeToken,
2979
+ method: "/media/abr_mezzanine/init",
2980
+ body: abrMezInitBody,
2981
+ constant: false,
2982
+ format: "text"
2983
+ });
2984
+
2985
+ try {
2986
+ await this.CallBitcodeMethod({
2987
+ libraryId: targetLibraryId,
2988
+ objectId: targetObjectId,
2989
+ writeToken,
2990
+ method: "/media/live_to_vod/copy",
2991
+ body: {},
2992
+ constant: false,
2993
+ format: "text"
2994
+ });
2995
+ } catch(error) {
2996
+ console.error("Unable to call /media/live_to_vod/copy", error);
2997
+ throw error;
2998
+ }
2999
+
3000
+ await this.CallBitcodeMethod({
3001
+ libraryId: targetLibraryId,
3002
+ objectId: targetObjectId,
3003
+ writeToken,
3004
+ method: "/media/abr_mezzanine/offerings/default/finalize",
3005
+ body: abrMezInitBody,
3006
+ constant: false,
3007
+ format: "text"
3008
+ });
3009
+
3010
+ if(finalize) {
3011
+ const finalizeResponse = await this.FinalizeContentObject({
3012
+ libraryId: targetLibraryId,
3013
+ objectId: targetObjectId,
3014
+ writeToken,
3015
+ commitMessage: "Live Stream to VoD"
3016
+ });
3017
+
3018
+ status.targetHash = finalizeResponse.hash;
3019
+ }
3020
+
3021
+ // Clean up unnecessary status items
3022
+ delete status.playoutUrls;
3023
+ delete status.lroStatusUrl;
3024
+ delete status.recordingPeriod;
3025
+ delete status.recordingPeriodSequence;
3026
+ delete status.edgeMetaSize;
3027
+ delete status.insertions;
3028
+
3029
+ return status;
3030
+ } catch(error) {
3031
+ this.Log(error, true);
3032
+ throw error;
3033
+ }
3034
+ };
3035
+
3036
+ /**
3037
+ * Remove a watermark for a live stream
3038
+ *
3039
+ * @methodGroup Live Stream
3040
+ * @namedParams
3041
+ * @param {string=} libraryId - Library ID of the live stream
3042
+ * @param {string} objectId - Object ID of the live stream
3043
+ * @param {string=} writeToken - Write token of the draft
3044
+ * @param {Array<string>} types - Specify which type of watermark to remove. Possible values:
3045
+ * - "image"
3046
+ * - "text"
3047
+ * - "forensic"
3048
+ * @param {boolean=} finalize - If enabled, target object will be finalized after removing watermark
3049
+ *
3050
+ * @return {Promise<Object>} - The finalize response
3051
+ */
3052
+ exports.StreamRemoveWatermark = async function({
3053
+ libraryId,
3054
+ objectId,
3055
+ writeToken,
3056
+ types,
3057
+ finalize=true
3058
+ }) {
3059
+ ValidateObject(objectId);
3060
+
3061
+ if(!libraryId) {
3062
+ libraryId = await this.ContentObjectLibraryId({objectId});
3063
+ }
3064
+
3065
+ if(!writeToken) {
3066
+ ({writeToken} = await this.EditContentObject({
3067
+ objectId,
3068
+ libraryId
3069
+ }));
3070
+ }
3071
+
3072
+ this.Log(`Removing watermark types: ${types.join(", ")} ${libraryId} ${objectId}`);
3073
+
3074
+ const edgeWriteToken = await this.ContentObjectMetadata({
3075
+ objectId,
3076
+ libraryId,
3077
+ metadataSubtree: "/live_recording/fabric_config/edge_write_token"
3078
+ });
3079
+
3080
+ const metadataPath = "live_recording/playout_config";
3081
+
3082
+ const objectMetadata = await this.ContentObjectMetadata({
3083
+ libraryId,
3084
+ objectId,
3085
+ writeToken,
3086
+ metadataSubtree: metadataPath,
3087
+ resolveLinks: false
3088
+ });
3089
+
3090
+ if(!objectMetadata) {
3091
+ throw Error("Stream object must be configured before removing a watermark");
3092
+ }
3093
+
3094
+ types.forEach(type => {
3095
+ if(type === "text") {
3096
+ delete objectMetadata.simple_watermark;
3097
+ } else if(type === "image") {
3098
+ delete objectMetadata.image_watermark;
3099
+ } else if(type === "forensic") {
3100
+ delete objectMetadata.forensic_watermark;
3101
+ }
3102
+ });
3103
+
3104
+ await this.ReplaceMetadata({
3105
+ libraryId,
3106
+ objectId,
3107
+ writeToken,
3108
+ metadataSubtree: metadataPath,
3109
+ metadata: objectMetadata
3110
+ });
3111
+
3112
+ if(edgeWriteToken) {
3113
+ await this.ReplaceMetadata({
3114
+ libraryId,
3115
+ objectId,
3116
+ writeToken: edgeWriteToken,
3117
+ metadataSubtree: metadataPath,
3118
+ metadata: objectMetadata
3119
+ });
3120
+ }
3121
+
3122
+ if(finalize) {
3123
+ const finalizeResponse = await this.FinalizeContentObject({
3124
+ libraryId,
3125
+ objectId,
3126
+ writeToken,
3127
+ commitMessage: "Watermark removed"
3128
+ });
3129
+
3130
+ return finalizeResponse;
3131
+ }
3132
+ };
3133
+
3134
+ /**
3135
+ * Create a watermark for a live stream
3136
+ *
3137
+ * @methodGroup Live Stream
3138
+ * @namedParams
3139
+ * @param {string=} libraryId - Library ID of the live stream
3140
+ * @param {string} objectId - Object ID of the live stream
3141
+ * @param {string=} writeToken - Write token of the draft
3142
+ * @param {Object} simpleWatermark - Text watermark
3143
+ * @param {Object} imageWatermark - Image watermark
3144
+ * @param {Object} forensicWatermark - Forensic watermark
3145
+ * @param {boolean=} finalize - If enabled, target object will be finalized after adding watermark
3146
+ * Watermark examples:
3147
+ *
3148
+ * Simple Watermark:
3149
+ {
3150
+ "font_color": "",
3151
+ "font_relative_height": 0,
3152
+ "shadow": false,
3153
+ "template": "",
3154
+ "timecode": "",
3155
+ "timecode_rate": 0,
3156
+ "x": "",
3157
+ "y": ""
3158
+ }
3159
+ *
3160
+ * Image watermark:
3161
+ {
3162
+ "image": "",
3163
+ "align_h": "",
3164
+ "align_v": "",
3165
+ "target_video_height": 0,
3166
+ "wm_enabled": false
3167
+ }
3168
+ *
3169
+ * Forensic watermark:
3170
+ {
3171
+ "algo": 6,
3172
+ "forensic_duration": 0,
3173
+ "forensic_start": "",
3174
+ "image_a": <path_to_image>,
3175
+ "image_b": <path_to_image>,
3176
+ "is_stub": true,
3177
+ "payload_bit_nb": 23,
3178
+ "wm_enabled": true
3179
+ }
3180
+ *
3181
+ *
3182
+ * @return {Promise<Object>} - The finalize response
3183
+ */
3184
+ exports.StreamAddWatermark = async function({
3185
+ libraryId,
3186
+ objectId,
3187
+ writeToken,
3188
+ simpleWatermark,
3189
+ imageWatermark,
3190
+ forensicWatermark,
3191
+ finalize=true
3192
+ }) {
3193
+ ValidateObject(objectId);
3194
+
3195
+ if(!libraryId) {
3196
+ libraryId = await this.ContentObjectLibraryId({objectId});
3197
+ }
3198
+
3199
+ if(!writeToken) {
3200
+ ({writeToken} = await this.EditContentObject({
3201
+ objectId,
3202
+ libraryId
3203
+ }));
3204
+ }
3205
+
3206
+ const edgeWriteToken = await this.ContentObjectMetadata({
3207
+ objectId,
3208
+ libraryId,
3209
+ metadataSubtree: "/live_recording/fabric_config/edge_write_token"
3210
+ });
3211
+
3212
+ const watermarkType = imageWatermark ? "image" : forensicWatermark ? "forensic" : "text";
3213
+ const metadataPath = "live_recording/playout_config";
3214
+
3215
+ this.Log(`Adding watermarking type: ${watermarkType} ${libraryId} ${objectId}`);
3216
+
3217
+ const objectMetadata = await this.ContentObjectMetadata({
3218
+ libraryId,
3219
+ objectId,
3220
+ writeToken,
3221
+ metadataSubtree: metadataPath,
3222
+ resolveLinks: false
3223
+ });
3224
+
3225
+ if(!objectMetadata) {
3226
+ throw Error("Stream object must be configured before adding a watermark");
3227
+ }
3228
+
3229
+ const watermarkArgCount = [simpleWatermark, imageWatermark, forensicWatermark].filter(i => !!i).length;
3230
+ console.log("watermark arg count", watermarkArgCount);
3231
+
3232
+ if(watermarkArgCount === 0) {
3233
+ throw Error("No watermark was provided");
3234
+ } else if(watermarkArgCount > 1) {
3235
+ throw Error("Only one watermark is allowed");
3236
+ }
3237
+
3238
+ if(simpleWatermark) {
3239
+ objectMetadata.simple_watermark = simpleWatermark;
3240
+ } else if(imageWatermark) {
3241
+ objectMetadata.image_watermark = imageWatermark;
3242
+ } else if(forensicWatermark) {
3243
+ objectMetadata.forensic_watermark = forensicWatermark;
3244
+ }
3245
+
3246
+ await this.ReplaceMetadata({
3247
+ libraryId,
3248
+ objectId,
3249
+ writeToken,
3250
+ metadataSubtree: metadataPath,
3251
+ metadata: objectMetadata
3252
+ });
3253
+
3254
+ if(edgeWriteToken) {
3255
+ await this.ReplaceMetadata({
3256
+ libraryId,
3257
+ objectId,
3258
+ writeToken: edgeWriteToken,
3259
+ metadataSubtree: metadataPath,
3260
+ metadata: objectMetadata
3261
+ });
3262
+ }
3263
+
3264
+ const response = {
3265
+ "imageWatermark": objectMetadata.image_watermark,
3266
+ "textWatermark": objectMetadata.simple_watermark,
3267
+ "forensicWatermark": objectMetadata.forensic_watermark
3268
+ };
3269
+
3270
+ if(finalize) {
3271
+ const finalizeResponse = await this.FinalizeContentObject({
3272
+ libraryId,
3273
+ objectId,
3274
+ writeToken,
3275
+ commitMessage: "Watermark set"
3276
+ });
3277
+
3278
+ response.hash = finalizeResponse.hash;
3279
+ }
3280
+
3281
+ return response;
3282
+ };
3283
+
3284
+ /**
3285
+ * Audit the specified live stream against several content fabric nodes
3286
+ *
3287
+ * @methodGroup Live Stream
3288
+ * @namedParams
3289
+ * @param {string=} objectId - Object ID of the live stream
3290
+ * @param {string=} versionHash - Version hash of the live stream -- if not specified, latest version is returned
3291
+ * @param {string=} salt - base64-encoded byte sequence for salting the audit hash
3292
+ * @param {Array<number>=} samples - list of percentages (0.0 - <1.0) used for sampling the content part list, up to 3
3293
+ * @param {string=} authorizationToken - Additional authorization token for this request
3294
+ *
3295
+ * @returns {Promise<Object>} - Response describing audit results
3296
+ */
3297
+ exports.AuditStream = async function({objectId, versionHash, salt, samples, authorizationToken}) {
3298
+ return await ContentObjectAudit.AuditContentObject({
3299
+ client: this,
3300
+ objectId,
3301
+ versionHash,
3302
+ salt,
3303
+ samples,
3304
+ live: true,
3305
+ authorizationToken
3306
+ });
3307
+ };
3308
+
3309
+ /**
3310
+ * @typedef {Object} LiveOutput
3311
+ * @property {boolean=} enabled - Whether the output is enabled
3312
+ * @property {string=} name - Display name for the output
3313
+ * @property {string=} description - Description of the output
3314
+ * @property {string=} external_id - External identifier for the output
3315
+ * @property {boolean=} reset - Whether to reset the output
3316
+ * @property {Object=} input - Input stream configuration
3317
+ * @property {string=} input.stream - Object ID of the input stream (null to disconnect)
3318
+ * @property {Object=} srt_pull - SRT pull delivery configuration
3319
+ * @property {Array<string>=} srt_pull.node_ids - Egress node IDs for SRT delivery (max 1)
3320
+ * @property {string=} srt_pull.passphrase - SRT passphrase for encrypted delivery
3321
+ * @property {boolean=} srt_pull.strip_rtp - Whether to strip RTP headers
3322
+ * @property {Object=} srt_pull.connection - Additional SRT connection configuration
3323
+ * @property {Array<string>=} srt_pull.urls - SRT URLs (returned by server, not set by caller)
3324
+ */
3325
+
3326
+ // Resolve egress node and replace SRT URL with the egress endpoint hostname.
3327
+ // Necessary because the backend API doesn't return the proper SRT URLs currently.
3328
+ exports.OutputsResolveSrtPullUrls = async function({value}) {
3329
+ const nodeId = value.srt_pull?.node_ids?.[0];
3330
+ if(!nodeId) { return value; }
3331
+
3332
+ const nodes = await this.SpaceNodes({matchNodeId: nodeId});
3333
+ const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
3334
+ if(fabricUrl) {
3335
+ const egressHost = new URL(fabricUrl).hostname;
3336
+ if(value.srt_pull?.urls) {
3337
+ value.srt_pull.urls = value.srt_pull.urls.map(url =>
3338
+ url.replace(/^srt:\/\/[^:/?]+/, `srt://${egressHost}`)
3339
+ );
3340
+ }
3341
+ }
3342
+
3343
+ return value;
3344
+ };
3345
+
3346
+ /**
3347
+ * List all live outputs for a stream object, optionally including live state.
3348
+ *
3349
+ * @methodGroup Live Stream
3350
+ * @namedParams
3351
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3352
+ * @param {string} objectId - Object ID of the output settings object
3353
+ * @param {boolean=} includeState - If true, also retrieve live state from each output's egress node (default: true)
3354
+ *
3355
+ * @returns {Promise<Object<string, LiveOutput>>} - Map of output IDs to LiveOutput objects, each optionally with a `state` field
3356
+ */
3357
+ exports.OutputsList = async function({libraryId, objectId, includeState=true}) {
3358
+ ValidateObject(objectId);
3359
+
3360
+ if(!libraryId) {
3361
+ libraryId = await this.ContentObjectLibraryId({objectId});
3362
+ }
3363
+
3364
+ // Route to any live egress node for the initial list call (only necessary until the API is globally available)
3365
+ const {restore} = await RouteToLiveEgress({client: this});
3366
+
3367
+ let outputs;
3368
+ try {
3369
+ outputs = await this.CallBitcodeMethod({
3370
+ libraryId,
3371
+ objectId,
3372
+ method: "live/outputs",
3373
+ constant: true
3374
+ });
3375
+ } finally {
3376
+ restore();
3377
+ }
3378
+
3379
+ for(let [key, value] of Object.entries(outputs)) {
3380
+ const streamId = value.input?.stream;
3381
+
3382
+ if(streamId) {
3383
+ const streamMetadata = await this.ContentObjectMetadata({
3384
+ libraryId: await this.ContentObjectLibraryId({objectId: streamId}),
3385
+ objectId: streamId,
3386
+ metadataSubtree: "/public/name",
3387
+ });
3388
+
3389
+ const streamStatus = await this.StreamStatus({name: streamId});
3390
+
3391
+ value.input.name = streamMetadata;
3392
+ value.input.status = streamStatus?.state;
3393
+ }
3394
+
3395
+ value = await this.OutputsResolveSrtPullUrls({value});
3396
+
3397
+ if(includeState) {
3398
+ try {
3399
+ const nodeId = value.srt_pull?.node_ids?.[0];
3400
+ const result = await this.OutputsState({outputId: key, objectId, libraryId, nodeId, includeState: true});
3401
+ value.state = result.state;
3402
+ } catch(error) {
3403
+ this.Log(`Failed to retrieve state for output ${key}: ${error.message}`, true);
3404
+ value.state = {};
3405
+ }
3406
+ }
3407
+ }
3408
+
3409
+ return outputs;
3410
+ };
3411
+
3412
+ /**
3413
+ * Get the configuration of a single live output by ID, optionally including live state.
3414
+ *
3415
+ * @methodGroup Live Stream
3416
+ * @namedParams
3417
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3418
+ * @param {string} objectId - Object ID of the output settings object
3419
+ * @param {string} outputId - ID of the output to retrieve
3420
+ * @param {boolean=} includeState - If true, also retrieve live state from the output's egress node (default: true)
3421
+ *
3422
+ * @returns {Promise<LiveOutput>} - Output config, optionally with a `state` field containing client_stats and srt_stats
1630
3423
  */
3424
+ exports.OutputsListItem = async function({libraryId, objectId, outputId, includeState=true}) {
3425
+ ValidateObject(objectId);
3426
+ ValidatePresence("outputId", outputId);
1631
3427
 
1632
- /*
1633
- Example fabric API flow:
3428
+ if(!libraryId) {
3429
+ libraryId = await this.ContentObjectLibraryId({objectId});
3430
+ }
1634
3431
 
1635
- https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/live_to_vod/init -d @r1 -H "Authorization: Bearer $TOK"
3432
+ const {restore} = await RouteToLiveEgress({client: this});
1636
3433
 
1637
- {
1638
- "live_qhash": "hq__5Zk1jSN8vNLUAXjQwMJV8F8J8ESXNvmVKkhaXySmGc1BXnJPG2FvvaXee4CXqvFHuGuU3fqLJc",
1639
- "start_time": "",
1640
- "end_time": "",
1641
- "recording_period": -1,
1642
- "streams": ["video", "audio"],
1643
- "variant_key": "default"
1644
- }
3434
+ let outputs;
3435
+ try {
3436
+ outputs = await this.CallBitcodeMethod({
3437
+ libraryId,
3438
+ objectId,
3439
+ method: "live/outputs",
3440
+ constant: true
3441
+ });
3442
+ } finally {
3443
+ restore();
3444
+ }
1645
3445
 
1646
- https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/init -H "Authorization: Bearer $TOK" -d @r2
3446
+ let value = outputs[outputId];
1647
3447
 
1648
- {
3448
+ if(!value) {
3449
+ throw new Error(`Output not found: ${outputId}`);
3450
+ }
1649
3451
 
1650
- "abr_profile": { ... },
1651
- "offering_key": "default",
1652
- "prod_master_hash": "tqw__HSQHBt7vYxWfCMPH5yXwKTfhdPcQ4Lcs9WUMUbTtnMbTZPTLo4BfJWPMGpoy1Dpv1wWQVtUtAtAr429TnVs",
1653
- "variant_key": "default",
1654
- "keep_other_streams": false
1655
- }
3452
+ const streamId = value.input?.stream;
1656
3453
 
1657
- 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"
3454
+ if(streamId) {
3455
+ const streamMetadata = await this.ContentObjectMetadata({
3456
+ libraryId: await this.ContentObjectLibraryId({objectId: streamId}),
3457
+ objectId: streamId,
3458
+ metadataSubtree: "/public/name",
3459
+ });
1658
3460
 
3461
+ const streamStatus = await this.StreamStatus({name: streamId});
1659
3462
 
1660
- https://host-76-74-34-194.contentfabric.io/qlibs/ilib24CtWSJeVt9DiAzym8jB6THE9e7H/q/$QWT/call/media/abr_mezzanine/offerings/default/finalize -d '{}' -H "Authorization: Bearer $TOK"
3463
+ value.input.name = streamMetadata;
3464
+ value.input.status = streamStatus?.state;
3465
+ }
3466
+
3467
+ value = await this.OutputsResolveSrtPullUrls({value});
3468
+
3469
+ if(includeState) {
3470
+ try {
3471
+ const nodeId = value.srt_pull?.node_ids?.[0];
3472
+ const result = await this.OutputsState({outputId, objectId, libraryId, nodeId, includeState: true});
3473
+ value.state = result.state;
3474
+ } catch(error) {
3475
+ this.Log(`Failed to retrieve state for output ${outputId}: ${error.message}`, true);
3476
+ value.state = {};
3477
+ }
3478
+ }
3479
+
3480
+ return value;
3481
+ };
1661
3482
 
3483
+ /**
3484
+ * Get the configuration of a specific live output, optionally including live state.
3485
+ * Retrieves the output config from a live egress node. If includeState is true, also
3486
+ * queries the output's specific egress node for live client and SRT stats.
3487
+ *
3488
+ * @methodGroup Live Stream
3489
+ * @namedParams
3490
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3491
+ * @param {string} objectId - Object ID of the output settings object
3492
+ * @param {string} outputId - ID of the output to retrieve state for
3493
+ * @param {string=} nodeId - Node ID to query for state. If not provided, it will be retrieved from the output's config.
3494
+ * @param {boolean=} includeState - If true, also retrieve live state from the output's egress node (default: true)
3495
+ *
3496
+ * @returns {Promise<LiveOutput>} - Output config, optionally with a `state` field containing client_stats and srt_stats
1662
3497
  */
3498
+ exports.OutputsState = async function({libraryId, objectId, outputId, nodeId, includeState=true}) {
3499
+ ValidateObject(objectId);
3500
+ ValidatePresence("outputId", outputId);
1663
3501
 
1664
- exports.StreamCopyToVod = async function({
1665
- name,
1666
- targetObjectId,
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");
3502
+ if(!libraryId) {
3503
+ libraryId = await this.ContentObjectLibraryId({objectId});
3504
+ }
1676
3505
 
1677
- const status = await this.StreamStatus({name});
1678
- const libraryId = status.library_id;
3506
+ // Route to a live egress node first so the output config fetch below succeeds
3507
+ const {restore} = await RouteToLiveEgress({client: this});
1679
3508
 
1680
- this.Log(`Copying stream ${name} to target ${targetObjectId}`);
3509
+ try {
3510
+ const {config} = await RouteToOutputNode({client: this, libraryId, objectId, outputId, nodeId});
1681
3511
 
1682
- ValidateObject(targetObjectId);
3512
+ if(!includeState) {
3513
+ return config;
3514
+ }
1683
3515
 
1684
- const targetLibraryId = await this.ContentObjectLibraryId({objectId: targetObjectId});
3516
+ const state = await this.CallBitcodeMethod({
3517
+ libraryId,
3518
+ objectId,
3519
+ method: UrlJoin("live", "outputs", outputId, "state"),
3520
+ queryParams: {
3521
+ "client_stats": 1,
3522
+ "srt_stats": 1
3523
+ },
3524
+ constant: true
3525
+ });
1685
3526
 
1686
- // Validation - ensure target object has content encryption keys
1687
- const kmsAddress = await this.authClient.KMSAddress({objectId: targetObjectId});
1688
- const kmsCapId = `eluv.caps.ikms${this.utils.AddressToHash(kmsAddress)}`;
1689
- const kmsCap = await this.ContentObjectMetadata({
1690
- libraryId: targetLibraryId,
1691
- objectId: targetObjectId,
1692
- metadataSubtree: kmsCapId
1693
- });
3527
+ return {
3528
+ ...config,
3529
+ state
3530
+ };
3531
+ } finally {
3532
+ restore();
3533
+ }
3534
+ };
1694
3535
 
1695
- if(!kmsCap) {
1696
- throw Error(`No content encryption key set for object ${targetObjectId}`);
3536
+ /**
3537
+ * Pin (route) the client to the egress node for a specific output. Fetches the output config
3538
+ * to determine the node ID then sets the client fabric URIs.
3539
+ * Currently node ID is retrieved from srt_pull.node_ids (only srt_pull outputs are supported).
3540
+ * Assumes the client is already routed to an eligible egress node (via RouteToLiveEgress).
3541
+ * Returns a function that restores the original fabric URIs.
3542
+ *
3543
+ * @param {Object} client - ElvClient instance
3544
+ * @param {string} libraryId - Library ID of the output settings object
3545
+ * @param {string} objectId - Object ID of the output settings object
3546
+ * @param {string} outputId - ID of the output
3547
+ * @param {string=} nodeId - Node ID if already known (skips config fetch)
3548
+ * @returns {Promise<{restore: Function, config: Object}>} - restore function and output config
3549
+ */
3550
+ const RouteToOutputNode = async ({client, libraryId, objectId, outputId, nodeId}) => {
3551
+ const savedURIs = [...client.fabricURIs];
3552
+ const restore = () => client.SetNodes({fabricURIs: savedURIs});
3553
+
3554
+ let config;
3555
+ if(!nodeId) {
3556
+ config = await client.CallBitcodeMethod({
3557
+ libraryId,
3558
+ objectId,
3559
+ method: UrlJoin("live", "outputs", outputId),
3560
+ constant: true
3561
+ });
3562
+ nodeId = config?.srt_pull?.node_ids?.[0];
1697
3563
  }
1698
3564
 
1699
- try {
1700
- status.live_object_id = objectId;
3565
+ if(nodeId) {
3566
+ const nodes = await client.SpaceNodes({matchNodeId: nodeId});
3567
+ const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
3568
+ if(fabricUrl) {
3569
+ client.SetNodes({fabricURIs: [fabricUrl]});
3570
+ if(config) { config = await client.OutputsResolveSrtPullUrls({value: config}); }
3571
+ }
3572
+ }
1701
3573
 
1702
- const liveHash = await this.LatestVersionHash({objectId, libraryId});
1703
- status.live_hash = liveHash;
3574
+ return {restore, config};
3575
+ };
1704
3576
 
1705
- if(eventId) {
1706
- // Retrieve start and end times for the event
1707
- let event = await this.CueInfo({eventId, status});
1708
- if(event.eventStart && event.eventEnd) {
1709
- startTime = event.eventStart;
1710
- endTime = event.eventEnd;
1711
- }
3577
+ /**
3578
+ * Pin (route) the client to any eligible live egress node from the /config API.
3579
+ * Returns a function that restores the original fabric URIs.
3580
+ *
3581
+ * @param {Object} client - ElvClient instance
3582
+ * @returns {Promise<{restore: Function}>} - restore function
3583
+ */
3584
+ const RouteToLiveEgress = async ({client}) => {
3585
+ const savedURIs = [...client.fabricURIs];
3586
+ const restore = () => client.SetNodes({fabricURIs: savedURIs});
3587
+
3588
+ const nodeId = await RetrieveOutputNodeId({client});
3589
+
3590
+ if(nodeId) {
3591
+ const nodes = await client.SpaceNodes({matchNodeId: nodeId});
3592
+ const fabricUrl = nodes?.[0]?.services?.fabric_api?.urls?.[0];
3593
+ if(fabricUrl) {
3594
+ client.SetNodes({fabricURIs: [fabricUrl]});
1712
3595
  }
3596
+ }
1713
3597
 
1714
- const {writeToken} = await this.EditContentObject({
1715
- objectId: targetObjectId,
1716
- libraryId: targetLibraryId
1717
- });
3598
+ return {restore};
3599
+ };
1718
3600
 
1719
- status.target_object_id = targetObjectId;
1720
- status.target_library_id = targetLibraryId;
1721
- status.target_write_token = writeToken;
3601
+ /**
3602
+ * Resolve a node ID for live egress output. If nodeIds is provided, uses the first element directly.
3603
+ * Otherwise, calls the /config API (optionally filtered by geo) to get live_egress endpoints,
3604
+ * then resolves the first endpoint to a node ID via SpaceNodes.
3605
+ *
3606
+ * @param {Object} client - ElvClient instance
3607
+ * @param {Array<string>=} nodeIds - Explicit node IDs to use
3608
+ * @param {Array<string>=} geos - Geo regions to filter config API results (max 1)
3609
+ * @returns {Promise<string>} - A node ID for the output
3610
+ */
3611
+ const RetrieveOutputNodeId = async ({client, nodeIds, geos}) => {
3612
+ if(nodeIds) {
3613
+ return nodeIds[0];
3614
+ }
1722
3615
 
1723
- this.Log("Process live source (takes around 20 sec per hour of content)");
3616
+ const uri = new URI(client.ConfigUrl());
3617
+ uri.pathname("/config");
3618
+ if(geos && geos.length > 0) {
3619
+ uri.addSearch("elvgeo", geos[0]);
3620
+ }
1724
3621
 
1725
- await this.CallBitcodeMethod({
1726
- libraryId: targetLibraryId,
1727
- objectId: targetObjectId,
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
- });
3622
+ const fabricInfo = await client.utils.ResponseToJson(
3623
+ HttpClient.Fetch(uri.toString())
3624
+ );
1741
3625
 
1742
- const abrMezInitBody = {
1743
- abr_profile: abrProfile,
1744
- "offering_key": "default",
1745
- "prod_master_hash": writeToken,
1746
- "variant_key": "default",
1747
- "keep_other_streams": false
3626
+ const liveEgressUrls = fabricInfo.network.services.live_egress;
3627
+ if(!liveEgressUrls || liveEgressUrls.length === 0) {
3628
+ throw new Error("No live_egress endpoints found in fabric config");
3629
+ }
3630
+
3631
+ // Extract hostname from the first live_egress URL and resolve to a node ID
3632
+ const hostname = new URL(liveEgressUrls[0]).hostname;
3633
+ const nodes = await client.SpaceNodes({matchEndpoint: hostname});
3634
+ if(!nodes || nodes.length === 0) {
3635
+ throw new Error(`No node found matching live_egress endpoint: ${hostname}`);
3636
+ }
3637
+
3638
+ return nodes[0].id;
3639
+ };
3640
+
3641
+ /**
3642
+ * Create a new live output.
3643
+ *
3644
+ * At the current version of the live outputs API an output will be pinned to a node, by either:
3645
+ * - specifying the node directly in 'nodeIds'
3646
+ * - specifying an 'elvgeo' and use fabric config 'live_egress' services to pick a node ID
3647
+ *
3648
+ * Note: Output creation and modification is transactional. To create multiple outputs in a single
3649
+ * transaction, use EditContentObject to open a write token, call CallBitcodeMethod for each output,
3650
+ * then finalize with FinalizeContentObject. This method handles a single output end-to-end.
3651
+ *
3652
+ * @methodGroup Live Stream
3653
+ * @namedParams
3654
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3655
+ * @param {string} objectId - Object ID of the outputs settings object
3656
+ * @param {string=} streamObjectId - Object ID of the input stream to use as the output source
3657
+ * @param {string=} name - Display name for the output
3658
+ * @param {string=} description - Description of the output
3659
+ * @param {Array<string>=} nodeIds - Explicit node ID(s) for SRT delivery (max 1)
3660
+ * @param {Array<string>=} geos - Geo regions for SRT delivery (max 1) — used to resolve a node from live_egress endpoints
3661
+ * @param {string=} passphrase - SRT passphrase for encrypted delivery
3662
+ * @param {boolean=} stripRtp - Whether to strip RTP headers (default: false)
3663
+ * @param {Object=} srtConfig - Additional SRT connection configuration (see openapi-bitcode.html#tocssrtconnectionconfig)
3664
+ *
3665
+ * @returns {Promise<Object>} - The created output
3666
+ */
3667
+ exports.OutputsCreate = async function({
3668
+ libraryId,
3669
+ objectId,
3670
+ streamObjectId,
3671
+ enabled,
3672
+ name,
3673
+ description,
3674
+ externalId,
3675
+ nodeIds,
3676
+ geos=[],
3677
+ passphrase,
3678
+ stripRtp=false,
3679
+ srtConfig
3680
+ }) {
3681
+ ValidateObject(objectId);
3682
+
3683
+ if(nodeIds && geos.length > 0) {
3684
+ throw new Error("Specify either nodeIds or geos, not both");
3685
+ }
3686
+ if(nodeIds && nodeIds.length > 1) {
3687
+ throw new Error("Only one node ID is supported — nodeIds must have at most 1 element");
3688
+ }
3689
+ if(geos.length > 1) {
3690
+ throw new Error("Only one geo is supported — geos must have at most 1 element");
3691
+ }
3692
+
3693
+ if(!libraryId) {
3694
+ libraryId = await this.ContentObjectLibraryId({objectId});
3695
+ }
3696
+
3697
+ const resolvedNodeId = await RetrieveOutputNodeId({client: this, nodeIds, geos});
3698
+
3699
+ // Route to any live egress node
3700
+ const {restore} = await RouteToLiveEgress({client: this});
3701
+
3702
+ // Auto-generate passphrase if encryption is truthy
3703
+ if(srtConfig?.enforced_encryption && !passphrase) {
3704
+ passphrase = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(16))).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
3705
+ srtConfig["pb_keylen"] = 16;
3706
+ }
3707
+
3708
+ try {
3709
+ const output = {
3710
+ enabled: streamObjectId ? enabled : false, // Output must be disabled if no stream specified
3711
+ name,
3712
+ description,
3713
+ external_id: externalId,
3714
+ input: streamObjectId ? {stream: streamObjectId} : undefined,
3715
+ srt_pull: {
3716
+ connection: srtConfig ?? undefined,
3717
+ node_ids: [resolvedNodeId],
3718
+ passphrase,
3719
+ strip_rtp: stripRtp
3720
+ }
1748
3721
  };
1749
3722
 
1750
- await this.CallBitcodeMethod({
1751
- libraryId: targetLibraryId,
1752
- objectId: targetObjectId,
3723
+ const {writeToken} = await this.EditContentObject({libraryId, objectId});
3724
+
3725
+ // Note - you may create multiple outputs here, then finalize the transaction below
3726
+ const outputs = await this.CallBitcodeMethod({
3727
+ libraryId,
3728
+ objectId,
1753
3729
  writeToken,
1754
- method: "/media/abr_mezzanine/init",
1755
- body: abrMezInitBody,
3730
+ method: "live/outputs",
1756
3731
  constant: false,
1757
- format: "text"
3732
+ body: output
1758
3733
  });
1759
3734
 
1760
- try {
1761
- await this.CallBitcodeMethod({
1762
- libraryId: targetLibraryId,
1763
- objectId: targetObjectId,
1764
- writeToken,
1765
- method: "/media/live_to_vod/copy",
1766
- body: {},
1767
- constant: false,
1768
- format: "text"
1769
- });
1770
- } catch(error) {
1771
- console.error("Unable to call /media/live_to_vod/copy", error);
1772
- throw error;
3735
+ await this.FinalizeContentObject({
3736
+ libraryId,
3737
+ objectId,
3738
+ writeToken,
3739
+ commitMessage: "Create output"
3740
+ });
3741
+
3742
+ return outputs;
3743
+ } finally {
3744
+ restore();
3745
+ }
3746
+ };
3747
+
3748
+ /**
3749
+ * Modify an existing live output.
3750
+ *
3751
+ * Note: Supply all fields when modifying an output — read the current output first, then apply changes.
3752
+ *
3753
+ * Note: Output modification is transactional. To modify multiple outputs in a single
3754
+ * transaction, use EditContentObject to open a write token, call CallBitcodeMethod for each output,
3755
+ * then finalize with FinalizeContentObject. This method handles a single output end-to-end.
3756
+ *
3757
+ * @methodGroup Live Stream
3758
+ * @namedParams
3759
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3760
+ * @param {string} objectId - Object ID of the output settings object
3761
+ * @param {string} outputId - ID of the output to modify
3762
+ * @param {LiveOutput} output - Full output object to PUT (read the current output first, apply changes, then pass the result)
3763
+ * @param {string=} writeToken - Write token to use. If not provided, a new edit will be opened.
3764
+ * @param {boolean=} finalize - If true, finalize after modifying (default: true)
3765
+ *
3766
+ * @returns {Promise<Object>} - The modified output
3767
+ */
3768
+ exports.OutputsModify = async function({
3769
+ libraryId,
3770
+ objectId,
3771
+ outputId,
3772
+ output,
3773
+ writeToken,
3774
+ finalize=true
3775
+ }) {
3776
+ ValidateObject(objectId);
3777
+ ValidatePresence("output", output);
3778
+
3779
+ if(!libraryId) {
3780
+ libraryId = await this.ContentObjectLibraryId({objectId});
3781
+ }
3782
+
3783
+ // Route to any live egress node
3784
+ const {restore} = await RouteToLiveEgress({client: this});
3785
+
3786
+ try {
3787
+ if(!writeToken) {
3788
+ ({writeToken} = await this.EditContentObject({libraryId, objectId}));
1773
3789
  }
1774
3790
 
1775
- await this.CallBitcodeMethod({
1776
- libraryId: targetLibraryId,
1777
- objectId: targetObjectId,
3791
+ if(output.srt_pull?.connection?.enforced_encryption && !output.srt_pull?.passphrase) {
3792
+ output.srt_pull.passphrase = Buffer.from(
3793
+ globalThis.crypto.getRandomValues(new Uint8Array(16))
3794
+ ).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
3795
+ }
3796
+
3797
+ const outputs = await this.CallBitcodeMethod({
3798
+ libraryId,
3799
+ objectId,
1778
3800
  writeToken,
1779
- method: "/media/abr_mezzanine/offerings/default/finalize",
1780
- body: abrMezInitBody,
3801
+ method: UrlJoin("live", "outputs", outputId),
3802
+ verb: "PUT",
1781
3803
  constant: false,
1782
- format: "text"
3804
+ body: output
1783
3805
  });
1784
3806
 
1785
3807
  if(finalize) {
1786
- const finalizeResponse = await this.FinalizeContentObject({
1787
- libraryId: targetLibraryId,
1788
- objectId: targetObjectId,
3808
+ await this.FinalizeContentObject({
3809
+ libraryId,
3810
+ objectId,
1789
3811
  writeToken,
1790
- commitMessage: "Live Stream to VoD"
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;
3812
+ commitMessage: "Modify output"
3813
+ });
3814
+ }
1803
3815
 
1804
- return status;
1805
- } catch(error) {
1806
- this.Log(error, true);
1807
- throw error;
3816
+ return outputs;
3817
+ } finally {
3818
+ restore();
1808
3819
  }
1809
3820
  };
1810
3821
 
1811
3822
  /**
1812
- * Remove a watermark for a live stream
3823
+ * Modify multiple live outputs in a single transaction.
3824
+ *
3825
+ * Takes a map of output IDs to partial output configurations. Routes to any eligible live
3826
+ * egress node, opens a write token, posts the map to live/outputs, then finalizes.
3827
+ *
3828
+ * Example:
3829
+ * {
3830
+ * "out001": { "enabled": false, "input": { "stream": "iq__..." }, "name": "A03" },
3831
+ * "out002": { "enabled": true }
3832
+ * }
1813
3833
  *
1814
3834
  * @methodGroup Live Stream
1815
3835
  * @namedParams
1816
- * @param {string=} libraryId - Library ID of the live stream
1817
- * @param {string} objectId - Object ID of the live stream
1818
- * @param {string=} writeToken - Write token of the draft
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
3836
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3837
+ * @param {string} objectId - Object ID of the output settings object
3838
+ * @param {Object<string, LiveOutput>} outputs - Map of output IDs to output configurations
1824
3839
  *
1825
- * @return {Promise<Object>} - The finalize response
3840
+ * @returns {Promise<Object>} - The response from the bitcode call
1826
3841
  */
1827
- exports.StreamRemoveWatermark = async function({
1828
- libraryId,
1829
- objectId,
1830
- writeToken,
1831
- types,
1832
- finalize=true
1833
- }) {
3842
+ exports.OutputsModifyBatch = async function({libraryId, objectId, outputs}) {
1834
3843
  ValidateObject(objectId);
3844
+ ValidatePresence("outputs", outputs);
1835
3845
 
1836
3846
  if(!libraryId) {
1837
3847
  libraryId = await this.ContentObjectLibraryId({objectId});
1838
3848
  }
1839
3849
 
1840
- if(!writeToken) {
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
- });
3850
+ const {restore} = await RouteToLiveEgress({client: this});
1864
3851
 
1865
- if(!objectMetadata) {
1866
- throw Error("Stream object must be configured before removing a watermark");
1867
- }
3852
+ try {
3853
+ // Read all current outputs and merge changes on top
3854
+ const current = await this.CallBitcodeMethod({
3855
+ libraryId,
3856
+ objectId,
3857
+ method: "live/outputs",
3858
+ constant: true
3859
+ });
1868
3860
 
1869
- types.forEach(type => {
1870
- if(type === "text") {
1871
- delete objectMetadata.simple_watermark;
1872
- } else if(type === "image") {
1873
- delete objectMetadata.image_watermark;
1874
- } else if(type === "forensic") {
1875
- delete objectMetadata.forensic_watermark;
3861
+ const merged = {...current};
3862
+ for(const [id, changes] of Object.entries(outputs)) {
3863
+ merged[id] = {...(current[id] || {}), ...changes};
1876
3864
  }
1877
- });
1878
3865
 
1879
- await this.ReplaceMetadata({
1880
- libraryId,
1881
- objectId,
1882
- writeToken,
1883
- metadataSubtree: metadataPath,
1884
- metadata: objectMetadata
1885
- });
3866
+ const {writeToken} = await this.EditContentObject({libraryId, objectId});
1886
3867
 
1887
- if(edgeWriteToken) {
1888
- await this.ReplaceMetadata({
3868
+ const result = await this.CallBitcodeMethod({
1889
3869
  libraryId,
1890
3870
  objectId,
1891
- writeToken: edgeWriteToken,
1892
- metadataSubtree: metadataPath,
1893
- metadata: objectMetadata
3871
+ writeToken,
3872
+ method: "live/outputs",
3873
+ verb: "PUT",
3874
+ constant: false,
3875
+ body: merged
1894
3876
  });
1895
- }
1896
3877
 
1897
- if(finalize) {
1898
- const finalizeResponse = await this.FinalizeContentObject({
3878
+ await this.FinalizeContentObject({
1899
3879
  libraryId,
1900
3880
  objectId,
1901
3881
  writeToken,
1902
- commitMessage: "Watermark removed"
3882
+ commitMessage: "Modify outputs (batch)"
1903
3883
  });
1904
3884
 
1905
- return finalizeResponse;
3885
+ return result;
3886
+ } finally {
3887
+ restore();
1906
3888
  }
1907
3889
  };
1908
3890
 
1909
3891
  /**
1910
- * Create a watermark for a live stream
3892
+ * Stop a live output.
1911
3893
  *
1912
3894
  * @methodGroup Live Stream
1913
3895
  * @namedParams
1914
- * @param {string=} libraryId - Library ID of the live stream
1915
- * @param {string} objectId - Object ID of the live stream
1916
- * @param {string=} writeToken - Write token of the draft
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
- *
3896
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3897
+ * @param {string} objectId - Object ID of the output settings object
3898
+ * @param {string} outputId - ID of the output to stop
1956
3899
  *
1957
- * @return {Promise<Object>} - The finalize response
3900
+ * @returns {Promise<Object>} - Response from the stop call
1958
3901
  */
1959
- exports.StreamAddWatermark = async function({
1960
- libraryId,
1961
- objectId,
1962
- writeToken,
1963
- simpleWatermark,
1964
- imageWatermark,
1965
- forensicWatermark,
1966
- finalize=true
1967
- }) {
3902
+ exports.OutputsStop = async function({libraryId, objectId, outputId}) {
1968
3903
  ValidateObject(objectId);
3904
+ ValidatePresence("outputId", outputId);
1969
3905
 
1970
3906
  if(!libraryId) {
1971
3907
  libraryId = await this.ContentObjectLibraryId({objectId});
1972
3908
  }
1973
3909
 
1974
- if(!writeToken) {
1975
- ({writeToken} = await this.EditContentObject({
1976
- objectId,
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}`);
3910
+ // Route to a live egress node, then to the specific output's node
3911
+ const {restore} = await RouteToLiveEgress({client: this});
3912
+ await RouteToOutputNode({client: this, libraryId, objectId, outputId});
1991
3913
 
1992
- const objectMetadata = await this.ContentObjectMetadata({
1993
- libraryId,
1994
- objectId,
1995
- writeToken,
1996
- metadataSubtree: metadataPath,
1997
- resolveLinks: false
1998
- });
3914
+ try {
3915
+ const {writeToken} = await this.EditContentObject({libraryId, objectId});
1999
3916
 
2000
- if(!objectMetadata) {
2001
- throw Error("Stream object must be configured before adding a watermark");
3917
+ return await this.CallBitcodeMethod({
3918
+ libraryId,
3919
+ objectId,
3920
+ writeToken,
3921
+ method: UrlJoin("live", "outputs", outputId, "ctrl", "stop"),
3922
+ constant: false
3923
+ });
3924
+ } finally {
3925
+ restore();
2002
3926
  }
3927
+ };
2003
3928
 
2004
- const watermarkArgCount = [simpleWatermark, imageWatermark, forensicWatermark].filter(i => !!i).length;
2005
- console.log("watermark arg count", watermarkArgCount);
3929
+ /**
3930
+ * Delete a live output.
3931
+ *
3932
+ * @methodGroup Live Stream
3933
+ * @namedParams
3934
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3935
+ * @param {string} objectId - Object ID of the output settings object
3936
+ * @param {string} outputId - ID of the output to delete
3937
+ *
3938
+ * @returns {Promise<Object>} - Response from the delete call
3939
+ */
3940
+ exports.OutputsDelete = async function({libraryId, objectId, outputId}) {
3941
+ ValidateObject(objectId);
3942
+ ValidatePresence("outputId", outputId);
2006
3943
 
2007
- if(watermarkArgCount === 0) {
2008
- throw Error("No watermark was provided");
2009
- } else if(watermarkArgCount > 1) {
2010
- throw Error("Only one watermark is allowed");
3944
+ if(!libraryId) {
3945
+ libraryId = await this.ContentObjectLibraryId({objectId});
2011
3946
  }
2012
3947
 
2013
- if(simpleWatermark) {
2014
- objectMetadata.simple_watermark = simpleWatermark;
2015
- } else if(imageWatermark) {
2016
- objectMetadata.image_watermark = imageWatermark;
2017
- } else if(forensicWatermark) {
2018
- objectMetadata.forensic_watermark = forensicWatermark;
2019
- }
3948
+ // Route to any live egress node
3949
+ const {restore} = await RouteToLiveEgress({client: this});
2020
3950
 
2021
- await this.ReplaceMetadata({
2022
- libraryId,
2023
- objectId,
2024
- writeToken,
2025
- metadataSubtree: metadataPath,
2026
- metadata: objectMetadata
2027
- });
3951
+ try {
3952
+ const {writeToken} = await this.EditContentObject({libraryId, objectId});
2028
3953
 
2029
- if(edgeWriteToken) {
2030
- await this.ReplaceMetadata({
3954
+ const result = await this.CallBitcodeMethod({
2031
3955
  libraryId,
2032
3956
  objectId,
2033
- writeToken: edgeWriteToken,
2034
- metadataSubtree: metadataPath,
2035
- metadata: objectMetadata
3957
+ writeToken,
3958
+ method: UrlJoin("live", "outputs", outputId),
3959
+ verb: "DELETE",
3960
+ constant: false,
2036
3961
  });
2037
- }
2038
-
2039
- const response = {
2040
- "imageWatermark": objectMetadata.image_watermark,
2041
- "textWatermark": objectMetadata.simple_watermark,
2042
- "forensicWatermark": objectMetadata.forensic_watermark
2043
- };
2044
3962
 
2045
- if(finalize) {
2046
- const finalizeResponse = await this.FinalizeContentObject({
3963
+ await this.FinalizeContentObject({
2047
3964
  libraryId,
2048
3965
  objectId,
2049
3966
  writeToken,
2050
- commitMessage: "Watermark set"
3967
+ commitMessage: "Remove output"
2051
3968
  });
2052
3969
 
2053
- response.hash = finalizeResponse.hash;
3970
+ return result;
3971
+ } finally {
3972
+ restore();
2054
3973
  }
2055
-
2056
- return response;
2057
3974
  };
2058
3975
 
2059
3976
  /**
2060
- * Audit the specified live stream against several content fabric nodes
3977
+ * Delete multiple live outputs in a single operation.
2061
3978
  *
2062
3979
  * @methodGroup Live Stream
2063
3980
  * @namedParams
2064
- * @param {string=} objectId - Object ID of the live stream
2065
- * @param {string=} versionHash - Version hash of the live stream -- if not specified, latest version is returned
2066
- * @param {string=} salt - base64-encoded byte sequence for salting the audit hash
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
3981
+ * @param {string=} libraryId - Library ID of the output settings object. If not provided, it will be retrieved automatically.
3982
+ * @param {string} objectId - Object ID of the output settings object
3983
+ * @param {Array<string>} outputs - List of output IDs to delete
2069
3984
  *
2070
- * @returns {Promise<Object>} - Response describing audit results
3985
+ * @returns {Promise<Object>} - Response from the delete call
2071
3986
  */
2072
- exports.AuditStream = async function({objectId, versionHash, salt, samples, authorizationToken}) {
2073
- return await ContentObjectAudit.AuditContentObject({
2074
- client: this,
2075
- objectId,
2076
- versionHash,
2077
- salt,
2078
- samples,
2079
- live: true,
2080
- authorizationToken
2081
- });
3987
+ exports.OutputsDeleteBatch = async function({libraryId, objectId, outputs}) {
3988
+ ValidateObject(objectId);
3989
+ ValidatePresence("outputs", outputs);
3990
+
3991
+ if(!libraryId) {
3992
+ libraryId = await this.ContentObjectLibraryId({objectId});
3993
+ }
3994
+
3995
+ const {restore} = await RouteToLiveEgress({client: this});
3996
+
3997
+ try {
3998
+ const {writeToken} = await this.EditContentObject({libraryId, objectId});
3999
+
4000
+ const result = await this.CallBitcodeMethod({
4001
+ libraryId,
4002
+ objectId,
4003
+ writeToken,
4004
+ method: UrlJoin("live", "outputs", outputId),
4005
+ verb: "DELETE",
4006
+ constant: false,
4007
+ });
4008
+
4009
+ await this.FinalizeContentObject({
4010
+ libraryId,
4011
+ objectId,
4012
+ writeToken,
4013
+ commitMessage: "Remove outputs (batch)"
4014
+ });
4015
+
4016
+ return result;
4017
+ } finally {
4018
+ restore();
4019
+ }
2082
4020
  };