@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 +1 -1
- package/src/AuthorizationClient.js +2 -1
- package/src/ContentObjectAudit.js +4 -1
- package/src/ElvClient.js +8 -15
- package/src/HttpClient.js +17 -1
- package/src/NetworkUrls.js +9 -0
- package/src/client/ContentAccess.js +8 -22
- package/utilities/ChannelCreate.js +1 -1
- package/utilities/LibraryDownloadMp4.js +54 -8
- package/utilities/LibraryDownloadMp4Parallel.js +544 -0
- package/utilities/lib/concerns/Client.js +5 -0
package/package.json
CHANGED
|
@@ -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({
|
|
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({},
|
|
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 =
|
|
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
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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 {
|
|
@@ -200,7 +200,20 @@ class LibraryDownloadMp4 extends Utility {
|
|
|
200
200
|
.substring(0, 180);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
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
|
-
|
|
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 (
|
|
357
|
-
|
|
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
|
};
|