@eluvio/elv-client-js 4.2.15 → 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 (41) 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/AuthorizationClient.js +2 -1
  21. package/src/ContentObjectAudit.js +4 -1
  22. package/src/ElvClient.js +8 -15
  23. package/src/FrameClient.js +23 -2
  24. package/src/HttpClient.js +17 -1
  25. package/src/NetworkUrls.js +9 -0
  26. package/src/abr_profiles/abr_profile_live_drm.js +0 -10
  27. package/src/client/ContentAccess.js +8 -23
  28. package/src/client/LiveConf.js +149 -65
  29. package/src/client/LiveStream.js +2592 -654
  30. package/src/live_recording_config_profiles/live_recording_config_default.js +54 -0
  31. package/src/live_recording_config_profiles/live_stream_profile_full.json +143 -0
  32. package/testScripts/StreamUpdateLinks.js +95 -0
  33. package/utilities/ChannelCreate.js +1 -1
  34. package/utilities/LibraryDownloadMp4.js +54 -8
  35. package/utilities/LibraryDownloadMp4Parallel.js +544 -0
  36. package/utilities/LiveOutputs.js +149 -0
  37. package/utilities/StreamCreate.js +53 -0
  38. package/utilities/lib/concerns/Client.js +5 -0
  39. package/utilities/lib/helpers.js +5 -1
  40. package/utilities/tests/mocks/ElvClient.mock.js +9 -1
  41. package/utilities/tests/unit/StreamCreate.test.js +39 -0
@@ -0,0 +1,544 @@
1
+ // Utility that accepts a library ID and generates a download URL for each object inside of it
2
+ // OPTIMIZED: Parallel downloads with configurable concurrency
3
+ const R = require("ramda");
4
+ const { ModOpt, NewOpt } = require("./lib/options");
5
+ const Utility = require("./lib/Utility");
6
+ const { PublicMetadataPathArrayModel } = require("./lib/models/PublicMetadataPath");
7
+
8
+ const JSONConcern = require("./lib/concerns/JSON");
9
+ const ArgLibraryId = require("./lib/concerns/ArgLibraryId");
10
+ const Metadata = require("./lib/concerns/Metadata");
11
+ const FabricObject = require("./lib/concerns/FabricObject");
12
+
13
+ const path = require("path");
14
+ const fs = require("fs");
15
+ const https = require("https");
16
+
17
+ /**
18
+ * Manages a fixed block of N terminal lines for parallel progress display.
19
+ * Each slot occupies one dedicated line; updates use ANSI cursor movement so
20
+ * concurrent jobs never overwrite each other.
21
+ *
22
+ * Layout (after start()):
23
+ * [slot 0 line]
24
+ * [slot 1 line]
25
+ * ...
26
+ * [slot N-1 line]
27
+ * <- cursor stays here
28
+ *
29
+ * logAbove() inserts a new line above the block (permanent log output) and
30
+ * keeps the block intact below it.
31
+ */
32
+ class MultiProgressDisplay {
33
+ constructor(numSlots) {
34
+ this.numSlots = numSlots;
35
+ this.lines = new Array(numSlots).fill("");
36
+ }
37
+
38
+ /** Reserve N blank lines for the progress block. Call once before any updates. */
39
+ start() {
40
+ for (let i = 0; i < this.numSlots; i++) {
41
+ process.stdout.write("\n");
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Overwrite the line for `slot` with `text`.
47
+ * Cursor returns to the position below slot N-1 after each call.
48
+ */
49
+ update(slot, text) {
50
+ this.lines[slot] = text;
51
+ const up = this.numSlots - slot; // always >= 1
52
+ process.stdout.write(`\x1B[${up}A\r\x1B[2K${text}\x1B[${up}B`);
53
+ }
54
+
55
+ /**
56
+ * Print `text` as a persistent log line above the progress block.
57
+ * Uses CSI IL (insert line) so no existing slot content is lost.
58
+ */
59
+ logAbove(text) {
60
+ // Move cursor to slot 0 line, insert a blank line (shifts all slots down),
61
+ // write the message, then move down past all N slots back to base position.
62
+ process.stdout.write(
63
+ `\x1B[${this.numSlots}A` + // up to slot 0
64
+ `\x1B[1L` + // insert blank line; slots shift to +1..+N
65
+ `\r\x1B[2K${text}` + // write log line
66
+ `\x1B[${this.numSlots + 1}B` // down N+1 lines back to base
67
+ );
68
+ }
69
+
70
+ /** Clear all progress lines (call after all tasks finish). */
71
+ finish() {
72
+ for (let slot = 0; slot < this.numSlots; slot++) {
73
+ this.update(slot, "");
74
+ }
75
+ }
76
+ }
77
+
78
+ class LibraryDownloadMp4 extends Utility {
79
+ blueprint() {
80
+ return {
81
+ concerns: [JSONConcern, ArgLibraryId, Metadata, FabricObject],
82
+ options: [
83
+ ModOpt("libraryId", { demand: true }),
84
+ NewOpt("filter", {
85
+ descTemplate:
86
+ "JSON expression (or path to JSON file if starting with '@') to filter objects by (public) metadata",
87
+ type: "string",
88
+ }),
89
+ NewOpt("date", {
90
+ descTemplate: "include latest commit date/time if available",
91
+ type: "boolean",
92
+ }),
93
+ NewOpt("fields", {
94
+ coerce: PublicMetadataPathArrayModel,
95
+ descTemplate:
96
+ "Path(s) for additional metadata values to include (each must start with /public/)",
97
+ string: true,
98
+ type: "array",
99
+ }),
100
+ NewOpt("hash", {
101
+ descTemplate: "include latest version hash",
102
+ type: "boolean",
103
+ }),
104
+ NewOpt("name", {
105
+ descTemplate: "include object name if available",
106
+ type: "boolean",
107
+ }),
108
+ NewOpt("size", {
109
+ descTemplate: "include object total size",
110
+ type: "boolean",
111
+ }),
112
+ NewOpt("offering", {
113
+ descTemplate: "Offering name for download URL",
114
+ type: "string",
115
+ }),
116
+ NewOpt("format", {
117
+ descTemplate: "Format for download URL (default mp4)",
118
+ type: "string",
119
+ }),
120
+ NewOpt("downloadDir", {
121
+ descTemplate: "Directory to save files",
122
+ type: "string",
123
+ }),
124
+ NewOpt("failLog", {
125
+ descTemplate: "Write failures to a JSON file",
126
+ type: "string",
127
+ }),
128
+ NewOpt("manifest", {
129
+ descTemplate: "Path to a JSON manifest file that records completed downloads — used to skip already-downloaded objects on re-runs",
130
+ type: "string",
131
+ }),
132
+ NewOpt("concurrency", {
133
+ descTemplate: "Number of parallel downloads (default: 5)",
134
+ type: "number",
135
+ default: 5,
136
+ }),
137
+ ],
138
+ };
139
+ }
140
+
141
+ header() {
142
+ return `List and download objects for library ${this.args.libraryId}`;
143
+ }
144
+
145
+ sleep(ms) {
146
+ return new Promise((resolve) => setTimeout(resolve, ms));
147
+ }
148
+
149
+ async retry(fn, opts = {}) {
150
+ const { retries = 3, delay = 1000, onRetry = null } = opts;
151
+ let attempt = 0;
152
+ while (attempt < retries) {
153
+ try {
154
+ return await fn();
155
+ } catch (err) {
156
+ attempt++;
157
+ if (attempt >= retries) throw err;
158
+ if (onRetry) onRetry(err, attempt);
159
+ else this.logger.warn(`Retry ${attempt}/${retries} after error: ${err.message}`);
160
+ await this.sleep(delay * Math.pow(2, attempt));
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Run an array of async task factories with a max concurrency limit.
167
+ * Each task factory receives its worker slot index (0-based) so it can
168
+ * update the correct progress line.
169
+ */
170
+ async parallelLimit(tasks, limit) {
171
+ const results = new Array(tasks.length);
172
+ let index = 0;
173
+
174
+ const worker = async (slot) => {
175
+ while (index < tasks.length) {
176
+ const i = index++;
177
+ results[i] = await tasks[i](slot);
178
+ }
179
+ };
180
+
181
+ const numWorkers = Math.min(limit, tasks.length);
182
+ await Promise.all(Array.from({ length: numWorkers }, (_, slot) => worker(slot)));
183
+ return results;
184
+ }
185
+
186
+ /** Render a compact ASCII progress bar for download progress. */
187
+ renderBar(downloaded, totalSize, width = 24) {
188
+ const dlMB = (downloaded / 1e6).toFixed(1);
189
+ if (totalSize > 0) {
190
+ const pct = Math.min(100, (downloaded / totalSize) * 100);
191
+ const filled = Math.round((pct / 100) * width);
192
+ const bar = "█".repeat(filled) + "░".repeat(width - filled);
193
+ const totalMB = (totalSize / 1e6).toFixed(1);
194
+ return `[${bar}] ${pct.toFixed(1)}% ${dlMB}/${totalMB} MB`;
195
+ }
196
+ return `[${"▒".repeat(width)}] ${dlMB} MB`;
197
+ }
198
+
199
+ async downloadFile(url, filepath, slot, display) {
200
+ return this.retry(() => {
201
+ return new Promise((resolve, reject) => {
202
+ // Keep-alive agent to reuse TCP connections across downloads
203
+ const agent = new https.Agent({ keepAlive: true });
204
+ const filename = path.basename(filepath);
205
+
206
+ const startDownload = (currentUrl, redirCount = 0) => {
207
+ if (redirCount > 5) return reject(new Error("Too many redirects"));
208
+
209
+ const req = https.get(currentUrl, { agent }, (res) => {
210
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
211
+ const nextUrl = res.headers.location.startsWith("http")
212
+ ? res.headers.location
213
+ : new URL(res.headers.location, currentUrl).href;
214
+ return startDownload(nextUrl, redirCount + 1);
215
+ }
216
+
217
+ if (res.statusCode !== 200) {
218
+ return reject(new Error(`Download failed (HTTP ${res.statusCode})`));
219
+ }
220
+
221
+ const totalSize = parseInt(res.headers["content-length"] || "0", 10);
222
+ let downloaded = 0;
223
+
224
+ const writeStream = fs.createWriteStream(filepath);
225
+
226
+ res.on("data", (chunk) => {
227
+ downloaded += chunk.length;
228
+ const bar = this.renderBar(downloaded, totalSize);
229
+ const line = ` [${filename}] ${bar}`;
230
+ if (display != null && slot != null) {
231
+ display.update(slot, line);
232
+ } else {
233
+ process.stdout.write(`\r${line}`);
234
+ }
235
+ });
236
+
237
+ res.on("end", () => {
238
+ if (display == null) process.stdout.write("\n");
239
+ });
240
+
241
+ res.pipe(writeStream);
242
+ writeStream.on("finish", () => writeStream.close(resolve));
243
+ writeStream.on("error", (err) => {
244
+ fs.unlink(filepath, () => reject(err));
245
+ });
246
+ });
247
+
248
+ // 5-minute socket inactivity timeout — generous for large video files
249
+ req.setTimeout(300_000, () => {
250
+ req.abort();
251
+ reject(new Error("DOWNLOAD_TIMEOUT"));
252
+ });
253
+
254
+ req.on("error", (err) => {
255
+ if (err.code === "ECONNRESET" || err.code === "ETIMEDOUT") {
256
+ reject(new Error("DOWNLOAD_TIMEOUT"));
257
+ } else {
258
+ reject(err);
259
+ }
260
+ });
261
+ };
262
+
263
+ startDownload(url);
264
+ });
265
+ }, {
266
+ retries: 3,
267
+ onRetry: (err, attempt) => {
268
+ const reason = err.message === "DOWNLOAD_TIMEOUT" ? "timed out" : `failed: ${err.message}`;
269
+ if (display != null && slot != null) {
270
+ display.logAbove(` [WARN] ${path.basename(filepath)}: ${reason} — retry ${attempt}/3`);
271
+ } else {
272
+ this.logger.warn(`Download ${reason}. Retrying attempt ${attempt}...`);
273
+ }
274
+ },
275
+ });
276
+ }
277
+
278
+ sanitizeFilename(name, fallback) {
279
+ if (!name) return fallback;
280
+ return name
281
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
282
+ .replace(/_+/g, "_")
283
+ .substring(0, 180);
284
+ }
285
+
286
+ async processObject(e, client, libraryId, format, offering, targetDir, failedDownloads, failPath, completedIds, manifestPath, slot, display) {
287
+ const objectId = e.objectId;
288
+ const objectName = R.path(["metadata", "public", "name"], e) || objectId;
289
+ // Truncate long names so progress lines stay within one terminal line
290
+ const shortName = objectName.length > 40 ? objectName.substring(0, 37) + "..." : objectName;
291
+
292
+ const formattedObj = { object_id: objectId, name: objectName, version_hash: null, file_service_url: null, download_url: null };
293
+
294
+ const updateSlot = (text) => {
295
+ if (display != null && slot != null) {
296
+ display.update(slot, ` [${shortName}] ${text}`);
297
+ }
298
+ };
299
+
300
+ const logLine = (text) => {
301
+ if (display != null) {
302
+ display.logAbove(text);
303
+ } else {
304
+ this.logger.log(text);
305
+ }
306
+ };
307
+
308
+ // Skip if already recorded in the manifest
309
+ if (completedIds.has(objectId)) {
310
+ logLine(` SKIP ${shortName} — in manifest`);
311
+ updateSlot("skipped (manifest)");
312
+ formattedObj.download_url = "SKIPPED_ALREADY_EXISTS";
313
+ return formattedObj;
314
+ }
315
+
316
+ updateSlot("starting...");
317
+
318
+ try {
319
+ const versionHash = await this.retry(() =>
320
+ this.concerns.FabricObject.latestVersionHash({ libraryId, objectId })
321
+ );
322
+
323
+ formattedObj.version_hash = versionHash;
324
+
325
+ // Resolve the file service base URL for this object
326
+ const fileServiceUrl = await this.retry(() =>
327
+ client.FabricUrl({
328
+ versionHash,
329
+ call: "/media/files",
330
+ service: "files",
331
+ })
332
+ );
333
+
334
+ formattedObj.file_service_url = fileServiceUrl;
335
+
336
+ // Start transcoding job
337
+ const response = await this.retry(() =>
338
+ client.MakeFileServiceRequest({
339
+ versionHash,
340
+ path: "/call/media/files",
341
+ method: "POST",
342
+ body: { format, offering },
343
+ })
344
+ );
345
+
346
+ const jobId = response.job_id;
347
+ logLine(` START ${shortName} (job ${jobId})`);
348
+ logLine(` ID: ${objectId}`);
349
+ logLine(` Version hash: ${versionHash}`);
350
+ logLine(` File svc URL: ${fileServiceUrl}`);
351
+
352
+ // Poll until complete, with stall detection and absolute timeout
353
+ const STALL_TIMEOUT_MS = 10 * 60 * 1000; // 10 min without progress change → stall
354
+ const TOTAL_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2 h absolute cap
355
+ const TERMINAL_STATUSES = new Set(["completed", "failed", "error", "cancelled"]);
356
+
357
+ let status;
358
+ let lastProgress = -1;
359
+ let lastProgressAt = Date.now();
360
+ const jobStartedAt = Date.now();
361
+
362
+ do {
363
+ await this.sleep(2000);
364
+ status = await this.retry(() =>
365
+ client.MakeFileServiceRequest({
366
+ versionHash,
367
+ path: `/call/media/files/${jobId}`,
368
+ })
369
+ );
370
+
371
+ const progress = status?.progress || 0;
372
+ const now = Date.now();
373
+
374
+ if (progress !== lastProgress) {
375
+ lastProgress = progress;
376
+ lastProgressAt = now;
377
+ const filled = Math.round((progress / 100) * 20);
378
+ const bar = "█".repeat(filled) + "░".repeat(20 - filled);
379
+ updateSlot(`transcoding [${bar}] ${progress.toFixed(1)}%`);
380
+ }
381
+
382
+ if (TERMINAL_STATUSES.has(status?.status) && status?.status !== "completed") {
383
+ throw new Error(`Transcoding job ${jobId} ended with status "${status.status}"`);
384
+ }
385
+
386
+ if (now - lastProgressAt > STALL_TIMEOUT_MS) {
387
+ throw new Error(`Transcoding stalled at ${lastProgress.toFixed(1)}% for >10 min`);
388
+ }
389
+
390
+ if (now - jobStartedAt > TOTAL_TIMEOUT_MS) {
391
+ throw new Error(`Transcoding exceeded 2-hour absolute timeout`);
392
+ }
393
+ } while (status?.status !== "completed");
394
+
395
+ const filename = this.sanitizeFilename(status.filename, `${objectId}.mp4`);
396
+ const outputFile = path.join(targetDir, filename);
397
+
398
+ const downloadUrl = await this.retry(() =>
399
+ client.FabricUrl({
400
+ versionHash,
401
+ call: `/media/files/${jobId}/download`,
402
+ service: "files",
403
+ queryParams: {
404
+ "header-x_set_content_disposition": `attachment; filename=${filename}`,
405
+ },
406
+ })
407
+ );
408
+
409
+ formattedObj.download_url = downloadUrl;
410
+ updateSlot("downloading...");
411
+
412
+ await this.downloadFile(downloadUrl, outputFile, slot, display);
413
+
414
+ logLine(` DONE ${shortName} → ${filename}`);
415
+ logLine(` Download URL: ${downloadUrl}`);
416
+ updateSlot("done ✔");
417
+
418
+ // Record success in manifest immediately so re-runs skip this object
419
+ if (manifestPath) {
420
+ completedIds.add(objectId);
421
+ const entry = {
422
+ object_id: objectId,
423
+ name: objectName,
424
+ version_hash: versionHash,
425
+ filename,
426
+ download_url: downloadUrl,
427
+ completed_at: new Date().toISOString(),
428
+ };
429
+ const existing = fs.existsSync(manifestPath)
430
+ ? JSON.parse(fs.readFileSync(manifestPath, "utf8"))
431
+ : [];
432
+ existing.push(entry);
433
+ fs.writeFileSync(manifestPath, JSON.stringify(existing, null, 2));
434
+ }
435
+
436
+ return formattedObj;
437
+
438
+ } catch (err) {
439
+ logLine(` FAIL ${shortName}: ${err.message}`);
440
+ updateSlot(`failed: ${err.message}`);
441
+ const entry = {
442
+ object_id: objectId,
443
+ name: objectName,
444
+ error: err.message,
445
+ timestamp: new Date().toISOString(),
446
+ };
447
+ failedDownloads.push(entry);
448
+ if (failPath) {
449
+ // Rewrite the full array so the file is always valid JSON
450
+ fs.writeFileSync(failPath, JSON.stringify(failedDownloads, null, 2));
451
+ }
452
+ return formattedObj;
453
+ }
454
+ }
455
+
456
+ async body() {
457
+ const libraryId = this.args.libraryId;
458
+ const format = this.args.format || "mp4";
459
+ const offering = this.args.offering || "default";
460
+ const concurrency = Math.max(1, this.args.concurrency || 5);
461
+
462
+ const filter =
463
+ this.args.filter &&
464
+ this.concerns.JSON.parseStringOrFile({ strOrPath: this.args.filter });
465
+
466
+ if (!this.args.fields) this.args.fields = [];
467
+ const select = ["/public/name", ...this.args.fields];
468
+
469
+ let objectList = await this.concerns.ArgLibraryId.libObjectList({
470
+ filterOptions: { select, filter },
471
+ });
472
+
473
+ this.logger.log(`Found ${objectList.length} object(s). Running with concurrency=${concurrency}\n`);
474
+
475
+ const client = await this.concerns.Client.get();
476
+ const failedDownloads = [];
477
+
478
+ const targetDir = this.args.downloadDir
479
+ ? path.resolve(this.args.downloadDir)
480
+ : process.cwd();
481
+
482
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
483
+
484
+ // Create (or reset) the fail log immediately so it exists from the start
485
+ const failPath = this.args.failLog ? path.resolve(this.args.failLog) : null;
486
+ if (failPath) {
487
+ fs.writeFileSync(failPath, "[]");
488
+ this.logger.log(`Failure log: ${failPath}`);
489
+ }
490
+
491
+ // Load or create the manifest that tracks completed downloads across re-runs
492
+ const manifestPath = this.args.manifest ? path.resolve(this.args.manifest) : null;
493
+ let completedIds; // Set<string>
494
+ if (manifestPath) {
495
+ if (fs.existsSync(manifestPath)) {
496
+ const saved = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
497
+ completedIds = new Set(saved.map((e) => e.object_id));
498
+ this.logger.log(`Manifest: ${manifestPath} (${completedIds.size} already completed)`);
499
+ } else {
500
+ completedIds = new Set();
501
+ fs.writeFileSync(manifestPath, "[]");
502
+ this.logger.log(`Manifest: ${manifestPath} (new)`);
503
+ }
504
+ } else {
505
+ completedIds = new Set();
506
+ }
507
+ this.logger.log("");
508
+
509
+ // Start the multi-line progress display (one line per concurrent worker slot)
510
+ const numSlots = Math.min(concurrency, objectList.length);
511
+ const display = new MultiProgressDisplay(numSlots);
512
+ display.start();
513
+
514
+ // Each task factory receives its worker slot index from parallelLimit
515
+ const tasks = objectList.map((obj) => (slot) =>
516
+ this.processObject(obj, client, libraryId, format, offering, targetDir, failedDownloads, failPath, completedIds, manifestPath, slot, display)
517
+ );
518
+
519
+ const results = await this.parallelLimit(tasks, concurrency);
520
+
521
+ // Clear the progress lines before printing summary
522
+ display.finish();
523
+
524
+ // Summary
525
+ this.logger.log("\n=== SUMMARY ===");
526
+ this.logger.log(`Processed: ${objectList.length}`);
527
+ this.logger.log(`Successful: ${results.length - failedDownloads.length}`);
528
+ this.logger.log(`Failed: ${failedDownloads.length}`);
529
+
530
+ if (failedDownloads.length > 0) {
531
+ this.logger.warn("\n=== FAILED DOWNLOADS ===");
532
+ this.logger.logTable({ list: failedDownloads });
533
+ if (failPath) this.logger.warn(`Failures logged to: ${failPath}`);
534
+ }
535
+
536
+ return { results, failedDownloads };
537
+ }
538
+ }
539
+
540
+ if (require.main === module) {
541
+ Utility.cmdLineInvoke(LibraryDownloadMp4);
542
+ } else {
543
+ module.exports = LibraryDownloadMp4;
544
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * LiveOutputs.js - Manage live outputs
3
+ *
4
+ * Usage:
5
+ * PRIVATE_KEY=<key> node utilities/LiveOutputs.js <command> [options]
6
+ *
7
+ * Commands:
8
+ * list List all outputs with config and state
9
+ * status <output_id> Show config and live state for an output
10
+ * create --stream <id> [--node <id> | --geo <geo>] Create a new output
11
+ * [--passphrase <pass>] [--name <name>]
12
+ * modify <output_id> [--stream <id>] [--enable true|false] Modify an existing output
13
+ * [--passphrase <pass>] [--name <name>]
14
+ * delete <output_id> Delete an output
15
+ *
16
+ * Examples:
17
+ * node utilities/LiveOutputs.js list
18
+ * node utilities/LiveOutputs.js status out005
19
+ * node utilities/LiveOutputs.js create --stream iq__abc123 --node inod123 --name "My Output"
20
+ * node utilities/LiveOutputs.js create --stream iq__abc123 --geo na-west-north
21
+ * node utilities/LiveOutputs.js modify out005 --enable false
22
+ * node utilities/LiveOutputs.js modify out005 --stream iq_def456 --passphrase "new-secret" --name "Renamed"
23
+ * node utilities/LiveOutputs.js delete out005
24
+ */
25
+ const { ElvClient } = require("../src/ElvClient");
26
+
27
+ const OBJECT_ID = "iq__eiDtwuBbAfyJCQqFKS5drdeDToL"; // demov3
28
+
29
+ const Init = async () => {
30
+ const client = await ElvClient.FromNetworkName({networkName: "demov3"});
31
+ const wallet = client.GenerateWallet();
32
+ const signer = wallet.AddAccount({privateKey: process.env.PRIVATE_KEY});
33
+ client.SetSigner({signer});
34
+ client.ToggleLogging(false);
35
+ return client;
36
+ };
37
+
38
+ const List = async () => {
39
+ const client = await Init();
40
+ const outputs = await client.OutputsList({objectId: OBJECT_ID, includeState: true});
41
+ console.log(JSON.stringify(outputs, null, 2));
42
+ };
43
+
44
+ const Status = async (outputId) => {
45
+ const client = await Init();
46
+ const state = await client.OutputsState({objectId: OBJECT_ID, outputId, includeState: true});
47
+ console.log(JSON.stringify(state, null, 2));
48
+ };
49
+
50
+ const Create = async ({streamId, nodeId, geo, passphrase, name}) => {
51
+ const client = await Init();
52
+ const result = await client.OutputsCreate({
53
+ objectId: OBJECT_ID,
54
+ streamObjectId: streamId,
55
+ enabled: true,
56
+ name,
57
+ nodeIds: nodeId ? [nodeId] : undefined,
58
+ geos: geo ? [geo] : [],
59
+ passphrase,
60
+ stripRtp: true
61
+ });
62
+ console.log(JSON.stringify(result, null, 2));
63
+ };
64
+
65
+ const Modify = async (outputId, {streamId, enable, passphrase, name}) => {
66
+ const client = await Init();
67
+
68
+ // Read current output to use as base
69
+ let output = await client.OutputsState({objectId: OBJECT_ID, outputId, includeState: false})
70
+
71
+ if(streamId !== undefined) {
72
+ output.input = {stream: streamId};
73
+ }
74
+ if(enable !== undefined) {
75
+ output.enabled = enable;
76
+ }
77
+ if(passphrase !== undefined) {
78
+ output.srt_pull = output.srt_pull || {};
79
+ output.srt_pull.passphrase = passphrase;
80
+ }
81
+ if(name !== undefined) {
82
+ output.name = name;
83
+ }
84
+
85
+ const result = await client.OutputsModify({objectId: OBJECT_ID, outputId, output});
86
+ console.log(JSON.stringify(result, null, 2));
87
+ };
88
+
89
+ const Delete = async (outputId) => {
90
+ const client = await Init();
91
+ const result = await client.OutputsDelete({objectId: OBJECT_ID, outputId});
92
+ console.log(JSON.stringify(result, null, 2));
93
+ };
94
+
95
+ const Run = async (fn) => {
96
+ try {
97
+ await fn();
98
+ } catch(error) {
99
+ if(error.status) {
100
+ console.error(`${error.status} ${error.statusText} ${error.url || ""}`);
101
+ if(error.body) {
102
+ console.error(JSON.stringify(error.body, null, 2));
103
+ }
104
+ } else {
105
+ console.error(error.message || error);
106
+ }
107
+ process.exit(1);
108
+ }
109
+ };
110
+
111
+ const getArg = (args, flag) => args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined;
112
+
113
+ const [cmd, ...args] = process.argv.slice(2);
114
+
115
+ switch(cmd) {
116
+ case "list":
117
+ Run(List);
118
+ break;
119
+ case "status":
120
+ Run(() => Status(args[0]));
121
+ break;
122
+ case "create":
123
+ Run(() => Create({
124
+ streamId: getArg(args, "--stream"),
125
+ nodeId: getArg(args, "--node"),
126
+ geo: getArg(args, "--geo"),
127
+ passphrase: getArg(args, "--passphrase"),
128
+ name: getArg(args, "--name")
129
+ }));
130
+ break;
131
+ case "modify":
132
+ Run(() => Modify(args[0], {
133
+ streamId: getArg(args, "--stream"),
134
+ enable: args.includes("--enable") ? getArg(args, "--enable") === "true" : undefined,
135
+ passphrase: getArg(args, "--passphrase"),
136
+ name: getArg(args, "--name")
137
+ }));
138
+ break;
139
+ case "delete":
140
+ Run(() => Delete(args[0]));
141
+ break;
142
+ default:
143
+ console.log("Usage: PRIVATE_KEY=<key> node utilities/LiveOutputs.js <command>\n");
144
+ console.log(" list");
145
+ console.log(" status <output_id>");
146
+ console.log(" create --stream <stream_object_id> [--node <node_id> | --geo <geo>] [--passphrase <pass>] [--name <name>]");
147
+ console.log(" modify <output_id> [--stream <stream_object_id>] [--enable true|false] [--passphrase <pass>] [--name <name>]");
148
+ console.log(" delete <output_id>");
149
+ }