@eluvio/elv-client-js 4.2.15 → 4.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eluvio/elv-client-js",
3
- "version": "4.2.15",
3
+ "version": "4.2.16",
4
4
  "description": "Javascript client for the Eluvio Content Fabric",
5
5
  "main": "src/index.js",
6
6
  "author": "Kevin Talmadge",
@@ -1013,7 +1013,8 @@ class AuthorizationClient {
1013
1013
  }
1014
1014
 
1015
1015
  const kmsHttpClient = new HttpClient({
1016
- uris: kmsUrls
1016
+ uris: kmsUrls,
1017
+ networkName: this.client.networkName
1017
1018
  });
1018
1019
 
1019
1020
  return await kmsHttpClient.Request({
@@ -49,7 +49,10 @@ const ContentObjectAudit = {
49
49
  ]
50
50
  .filter((v, i, s) => s.indexOf(v) === i);
51
51
 
52
- const httpClient = new HttpClient({uris});
52
+ const httpClient = new HttpClient({
53
+ uris,
54
+ networkName: client.networkName
55
+ });
53
56
 
54
57
  let path = UrlJoin("qlibs", libraryId, "q", versionHash || objectId, live ? "call/live/audit" : "audit");
55
58
  let responses = await httpClient.RequestAll({
package/src/ElvClient.js CHANGED
@@ -11,6 +11,7 @@ const HttpClient = require("./HttpClient");
11
11
  const RemoteSigner = require("./RemoteSigner");
12
12
  const Utils = require("./Utils");
13
13
  const Crypto = require("./Crypto");
14
+ const NetworkUrls = require("./NetworkUrls");
14
15
  const {LogMessage} = require("./LogMessage");
15
16
 
16
17
  const Pako = require("pako");
@@ -21,14 +22,6 @@ const {
21
22
  } = require("./Validation");
22
23
  const UrlJoin = require("url-join");
23
24
 
24
- const networks = {
25
- "main": "https://main.net955305.contentfabric.io",
26
- "demo": "https://demov3.net955210.contentfabric.io",
27
- "demov3": "https://demov3.net955210.contentfabric.io",
28
- "local": "http://127.0.0.1:8008/config?qspace=dev&self",
29
- "test": "https://test.net955203.contentfabric.io"
30
- };
31
-
32
25
  if(Utils.Platform() === Utils.PLATFORM_NODE) {
33
26
  // Define Response in node
34
27
  // eslint-disable-next-line no-global-assign
@@ -302,7 +295,7 @@ class ElvClient {
302
295
  * @return {Object} - An object using network names as keys and configuration URLs as values.
303
296
  */
304
297
  static Networks() {
305
- return Object.assign({}, networks);
298
+ return Object.assign({}, NetworkUrls);
306
299
  }
307
300
 
308
301
  /**
@@ -332,7 +325,7 @@ class ElvClient {
332
325
  noAuth=false,
333
326
  assumeV3
334
327
  }) {
335
- const configUrl = networks[networkName];
328
+ const configUrl = this.Networks()[networkName];
336
329
 
337
330
  if(!configUrl) { throw Error("Invalid network name: " + networkName); }
338
331
 
@@ -430,10 +423,10 @@ class ElvClient {
430
423
  this.inaccessibleLibraries = {};
431
424
 
432
425
  const uris = this.service === "search" ? this.searchURIs : this.fabricURIs;
433
- this.HttpClient = new HttpClient({uris, debug: this.debug});
434
- this.AuthHttpClient = new HttpClient({uris: this.authServiceURIs, debug: this.debug});
435
- this.FileServiceHttpClient = new HttpClient({uris: this.fileServiceURIs, debug: this.debug});
436
- this.SearchHttpClient = new HttpClient({uris: this.searchURIs || [], debug: this.debug});
426
+ this.HttpClient = new HttpClient({uris, networkName: this.networkName, debug: this.debug});
427
+ this.AuthHttpClient = new HttpClient({uris: this.authServiceURIs, networkName: this.networkName, debug: this.debug});
428
+ this.FileServiceHttpClient = new HttpClient({uris: this.fileServiceURIs, networkName: this.networkName, debug: this.debug});
429
+ this.SearchHttpClient = new HttpClient({uris: this.searchURIs || [], networkName: this.networkName, debug: this.debug});
437
430
  this.ethClient = new EthClient({client: this, uris: this.ethereumURIs, networkId: this.networkId, debug: this.debug, timeout: this.ethereumContractTimeout});
438
431
 
439
432
  if(!this.signer) {
@@ -1224,7 +1217,7 @@ class ElvClient {
1224
1217
  this.oauthToken = token;
1225
1218
 
1226
1219
  const path = "/ks/jwt/wlt";
1227
- const httpClient = new HttpClient({uris: this.kmsURIs, debug: this.debug});
1220
+ const httpClient = new HttpClient({uris: this.kmsURIs, networkName: this.networkName, debug: this.debug});
1228
1221
 
1229
1222
  const response = await this.utils.ResponseToJson(
1230
1223
  httpClient.Request({
package/src/HttpClient.js CHANGED
@@ -2,17 +2,20 @@ const URI = require("urijs");
2
2
  const Fetch = typeof fetch !== "undefined" ? fetch : require("node-fetch").default;
3
3
  const {LogMessage} = require("./LogMessage");
4
4
  const Utils = require("./Utils");
5
+ const UrlJoin = require("url-join");
6
+ const NetworkUrls = require("./NetworkUrls");
5
7
 
6
8
  class HttpClient {
7
9
  Log(message, error=false) {
8
10
  LogMessage(this, message, error);
9
11
  }
10
12
 
11
- constructor({uris, debug}) {
13
+ constructor({uris, networkName, debug}) {
12
14
  this.uris = uris;
13
15
  this.uriIndex = 0;
14
16
  this.debug = debug;
15
17
  this.draftURIs = {};
18
+ this.networkName = networkName;
16
19
  this.retries = Math.max(3, uris.length);
17
20
  }
18
21
 
@@ -77,6 +80,19 @@ class HttpClient {
77
80
  // Use saved write token URI
78
81
  baseURI = this.draftURIs[writeToken];
79
82
  } else {
83
+ // Retrieve the node that this write token is for to ensure it is correct.
84
+ if(this.networkName) {
85
+ try {
86
+ const configUrl = new URL(NetworkUrls[this.networkName]);
87
+ configUrl.pathname = UrlJoin("/s", this.networkName, "nodes");
88
+ configUrl.searchParams.set("token", writeToken);
89
+ baseURI = new URI((await (await fetch(configUrl)).json()).nodes[0].services.fabric_api.urls[0]);
90
+ } catch(error) {
91
+ this.Log("Failed to retrieve write token node for " + writeToken);
92
+ this.Log(error);
93
+ }
94
+ }
95
+
80
96
  // Save current URI for all future requests involving this write token
81
97
  this.draftURIs[writeToken] = baseURI;
82
98
  }
@@ -0,0 +1,9 @@
1
+ const networks = {
2
+ "main": "https://main.net955305.contentfabric.io",
3
+ "demo": "https://demov3.net955210.contentfabric.io",
4
+ "demov3": "https://demov3.net955210.contentfabric.io",
5
+ "local": "http://127.0.0.1:8008/config?qspace=dev&self",
6
+ "test": "https://test.net955203.contentfabric.io"
7
+ };
8
+
9
+ module.exports = networks;
@@ -1786,17 +1786,14 @@ exports.GlobalUrl = async function({
1786
1786
  );
1787
1787
 
1788
1788
  // Pull auth out of query params
1789
- if(
1790
- queryParams.authorization &&
1791
- (
1792
- typeof queryParams.authorization === "string" ||
1793
- (Array.isArray(queryParams.authorization) && queryParams.authorization.length === 1)
1794
- )
1795
- ) {
1789
+ if(!queryParams.authorization) {
1796
1790
  queryParams = {...queryParams};
1797
- authorizationToken = typeof queryParams.authorization === "string" ?
1798
- queryParams.authorization :
1799
- queryParams.authorization[0];
1791
+ queryParams.authorization = await this.authClient.AuthorizationToken({
1792
+ libraryId,
1793
+ objectId,
1794
+ versionHash,
1795
+ noAuth
1796
+ });
1800
1797
  }
1801
1798
 
1802
1799
  if(writeToken) {
@@ -1807,19 +1804,8 @@ exports.GlobalUrl = async function({
1807
1804
  }
1808
1805
  }
1809
1806
 
1807
+ console.log("Updated")
1810
1808
  let urlPath = UrlJoin("s", network);
1811
- if(!noAuth || authorizationToken) {
1812
- urlPath = UrlJoin(
1813
- "t",
1814
- authorizationToken || await this.authClient.AuthorizationToken({
1815
- libraryId,
1816
- objectId,
1817
- versionHash,
1818
- noAuth
1819
- })
1820
- );
1821
- }
1822
-
1823
1809
  if(versionHash) {
1824
1810
  objectId = this.utils.DecodeVersionHash(versionHash).objectId;
1825
1811
  } else {
@@ -202,4 +202,4 @@ if(require.main === module) {
202
202
  Utility.cmdLineInvoke(ChannelCreate);
203
203
  } else {
204
204
  module.exports = ChannelCreate;
205
- }
205
+ }
@@ -200,7 +200,20 @@ class LibraryDownloadMp4 extends Utility {
200
200
  .substring(0, 180);
201
201
  }
202
202
 
203
- async processObject(e, client, libraryId, format, offering, targetDir, failedDownloads) {
203
+ appendFailEntry(failLogPath, entry) {
204
+ let entries = [];
205
+ if (fs.existsSync(failLogPath)) {
206
+ try {
207
+ entries = JSON.parse(fs.readFileSync(failLogPath, "utf8"));
208
+ } catch {
209
+ // If the file is malformed just start fresh
210
+ }
211
+ }
212
+ entries.push(entry);
213
+ fs.writeFileSync(failLogPath, JSON.stringify(entries, null, 2));
214
+ }
215
+
216
+ async processObject(e, client, libraryId, format, offering, targetDir, failedDownloads, failLogPath) {
204
217
  const objectId = e.objectId;
205
218
  const objectName = R.path(["metadata", "public", "name"], e) || objectId;
206
219
 
@@ -238,6 +251,8 @@ class LibraryDownloadMp4 extends Utility {
238
251
  // Poll job
239
252
  let status;
240
253
  let lastProgress = -1;
254
+ const maxPolls = 300; // 10 minutes at 2s intervals
255
+ let pollCount = 0;
241
256
 
242
257
  do {
243
258
  await this.sleep(2000);
@@ -249,6 +264,16 @@ class LibraryDownloadMp4 extends Utility {
249
264
  })
250
265
  );
251
266
 
267
+ const jobStatus = status?.status;
268
+ if (jobStatus === "failed" || jobStatus === "error") {
269
+ throw new Error(`Job ${jobId} failed with status: ${jobStatus}`);
270
+ }
271
+
272
+ pollCount++;
273
+ if (pollCount >= maxPolls) {
274
+ throw new Error(`Job ${jobId} timed out after ${maxPolls} polling attempts`);
275
+ }
276
+
252
277
  const progress = status?.progress || 0;
253
278
  if (progress !== lastProgress) {
254
279
  process.stdout.write(`Progress: ${progress.toFixed(1)}%\r`);
@@ -278,6 +303,11 @@ class LibraryDownloadMp4 extends Utility {
278
303
 
279
304
  formattedObj.download_url = downloadUrl;
280
305
 
306
+ if (fs.existsSync(outputFile)) {
307
+ this.logger.log(`Skipping ${objectName}: already exists (${filename})`);
308
+ return formattedObj;
309
+ }
310
+
281
311
  // NOW using HTTPS downloader
282
312
  this.logger.log(`Downloading → ${outputFile}`);
283
313
  await this.downloadFile(downloadUrl, outputFile);
@@ -288,12 +318,22 @@ class LibraryDownloadMp4 extends Utility {
288
318
  } catch (err) {
289
319
  this.logger.error(`FAILED: ${objectId} - ${err.message}`);
290
320
 
291
- failedDownloads.push({
321
+ const fileServiceUrl = client.FileServiceHttpClient?.uris?.[client.FileServiceHttpClient.uriIndex] || "unknown";
322
+
323
+ const failEntry = {
292
324
  object_id: objectId,
293
325
  name: objectName,
294
326
  error: err.message,
327
+ file_service_url: fileServiceUrl,
295
328
  timestamp: new Date().toISOString(),
296
- });
329
+ };
330
+
331
+ failedDownloads.push(failEntry);
332
+
333
+ if (failLogPath) {
334
+ this.appendFailEntry(failLogPath, failEntry);
335
+ this.logger.warn(`Failure recorded → ${failLogPath}`);
336
+ }
297
337
 
298
338
  return formattedObj;
299
339
  }
@@ -328,6 +368,13 @@ class LibraryDownloadMp4 extends Utility {
328
368
  if (!fs.existsSync(targetDir))
329
369
  fs.mkdirSync(targetDir, { recursive: true });
330
370
 
371
+ // Initialize fail log file with empty array at the start of the run
372
+ const failLogPath = this.args.failLog ? path.resolve(this.args.failLog) : null;
373
+ if (failLogPath) {
374
+ fs.writeFileSync(failLogPath, JSON.stringify([], null, 2));
375
+ this.logger.log(`Fail log initialized: ${failLogPath}`);
376
+ }
377
+
331
378
  // Sequential downloads
332
379
  const results = [];
333
380
  for (const obj of objectList) {
@@ -338,7 +385,8 @@ class LibraryDownloadMp4 extends Utility {
338
385
  format,
339
386
  offering,
340
387
  targetDir,
341
- failedDownloads
388
+ failedDownloads,
389
+ failLogPath
342
390
  );
343
391
  results.push(r);
344
392
  }
@@ -353,10 +401,8 @@ class LibraryDownloadMp4 extends Utility {
353
401
  this.logger.warn("\n=== FAILED DOWNLOADS ===");
354
402
  this.logger.logTable({ list: failedDownloads });
355
403
 
356
- if (this.args.failLog) {
357
- const failPath = path.resolve(this.args.failLog);
358
- fs.writeFileSync(failPath, JSON.stringify(failedDownloads, null, 2));
359
- this.logger.warn(`Failures written to: ${failPath}`);
404
+ if (failLogPath) {
405
+ this.logger.warn(`Full failure log: ${failLogPath}`);
360
406
  }
361
407
  }
362
408
 
@@ -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
+ }
@@ -31,6 +31,11 @@ const blueprint = {
31
31
  descTemplate: "Geographic region for the fabric nodes.",
32
32
  group: "API",
33
33
  type: "string"
34
+ }),
35
+ NewOpt("node", {
36
+ descTemplate: "Pin all fabric and file-service requests to a specific node hostname or URL (e.g. host-76-74-28-240.contentfabric.io). Overrides the nodes returned by the config URL.",
37
+ group: "API",
38
+ type: "string"
34
39
  })
35
40
  ]
36
41
  };