@eluvio/elv-client-js 4.2.14 → 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 +9 -22
- package/utilities/CompositionCreate.js +517 -0
- package/utilities/LibraryDownloadMp4.js +417 -0
- package/utilities/LibraryDownloadMp4Parallel.js +544 -0
- package/utilities/MezDownloadMp4.js +177 -0
- package/utilities/lib/DownloadFile.js +88 -0
- package/utilities/lib/FrameAccurateVideo.js +431 -0
- package/utilities/lib/concerns/Client.js +5 -0
- package/utilities/lib/concerns/Metadata.js +2 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Utility that accepts a library ID and generates a download URL for each object inside of it
|
|
2
|
+
const R = require("ramda");
|
|
3
|
+
const { ModOpt, NewOpt } = require("./lib/options");
|
|
4
|
+
const Utility = require("./lib/Utility");
|
|
5
|
+
const { PublicMetadataPathArrayModel } = require("./lib/models/PublicMetadataPath");
|
|
6
|
+
|
|
7
|
+
const JSONConcern = require("./lib/concerns/JSON");
|
|
8
|
+
const ArgLibraryId = require("./lib/concerns/ArgLibraryId");
|
|
9
|
+
const Metadata = require("./lib/concerns/Metadata");
|
|
10
|
+
const FabricObject = require("./lib/concerns/FabricObject");
|
|
11
|
+
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const https = require("https");
|
|
15
|
+
|
|
16
|
+
class LibraryDownloadMp4 extends Utility {
|
|
17
|
+
blueprint() {
|
|
18
|
+
return {
|
|
19
|
+
concerns: [JSONConcern, ArgLibraryId, Metadata, FabricObject],
|
|
20
|
+
options: [
|
|
21
|
+
ModOpt("libraryId", { demand: true }),
|
|
22
|
+
NewOpt("filter", {
|
|
23
|
+
descTemplate:
|
|
24
|
+
"JSON expression (or path to JSON file if starting with '@') to filter objects by (public) metadata",
|
|
25
|
+
type: "string",
|
|
26
|
+
}),
|
|
27
|
+
NewOpt("date", {
|
|
28
|
+
descTemplate: "include latest commit date/time if available",
|
|
29
|
+
type: "boolean",
|
|
30
|
+
}),
|
|
31
|
+
NewOpt("fields", {
|
|
32
|
+
coerce: PublicMetadataPathArrayModel,
|
|
33
|
+
descTemplate:
|
|
34
|
+
"Path(s) for additional metadata values to include (each must start with /public/)",
|
|
35
|
+
string: true,
|
|
36
|
+
type: "array",
|
|
37
|
+
}),
|
|
38
|
+
NewOpt("hash", {
|
|
39
|
+
descTemplate: "include latest version hash",
|
|
40
|
+
type: "boolean",
|
|
41
|
+
}),
|
|
42
|
+
NewOpt("name", {
|
|
43
|
+
descTemplate: "include object name if available",
|
|
44
|
+
type: "boolean",
|
|
45
|
+
}),
|
|
46
|
+
NewOpt("size", {
|
|
47
|
+
descTemplate: "include object total size",
|
|
48
|
+
type: "boolean",
|
|
49
|
+
}),
|
|
50
|
+
NewOpt("offering", {
|
|
51
|
+
descTemplate: "Offering name for download URL",
|
|
52
|
+
type: "string",
|
|
53
|
+
}),
|
|
54
|
+
NewOpt("format", {
|
|
55
|
+
descTemplate: "Format for download URL (default mp4)",
|
|
56
|
+
type: "string",
|
|
57
|
+
}),
|
|
58
|
+
NewOpt("downloadDir", {
|
|
59
|
+
descTemplate: "Directory to save files",
|
|
60
|
+
type: "string",
|
|
61
|
+
}),
|
|
62
|
+
NewOpt("failLog", {
|
|
63
|
+
descTemplate: "Write failures to a JSON file",
|
|
64
|
+
type: "string",
|
|
65
|
+
})
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
header() {
|
|
71
|
+
return `List and download objects for library ${this.args.libraryId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sleep(ms) {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async retry(fn, opts = {}) {
|
|
79
|
+
const {
|
|
80
|
+
retries = 3,
|
|
81
|
+
delay = 1000,
|
|
82
|
+
onRetry = null
|
|
83
|
+
} = opts;
|
|
84
|
+
|
|
85
|
+
let attempt = 0;
|
|
86
|
+
while (attempt < retries) {
|
|
87
|
+
try {
|
|
88
|
+
return await fn();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
attempt++;
|
|
91
|
+
if (attempt >= retries) throw err;
|
|
92
|
+
|
|
93
|
+
if (onRetry) onRetry(err, attempt);
|
|
94
|
+
else this.logger.warn(`Retry ${attempt}/${retries} after error: ${err.message}`);
|
|
95
|
+
|
|
96
|
+
await this.sleep(delay * Math.pow(2, attempt));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async downloadFile(url, filepath) {
|
|
103
|
+
return this.retry(() => {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const startDownload = (currentUrl, redirCount = 0) => {
|
|
106
|
+
if (redirCount > 5) {
|
|
107
|
+
return reject(new Error("Too many redirects"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const req = https.get(currentUrl, (res) => {
|
|
111
|
+
// Handle redirects
|
|
112
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
113
|
+
const nextUrl = res.headers.location.startsWith("http")
|
|
114
|
+
? res.headers.location
|
|
115
|
+
: new URL(res.headers.location, currentUrl).href;
|
|
116
|
+
|
|
117
|
+
this.logger.log(`Redirected → ${nextUrl}`);
|
|
118
|
+
return startDownload(nextUrl, redirCount + 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (res.statusCode !== 200) {
|
|
122
|
+
return reject(new Error(`Download failed (HTTP ${res.statusCode})`));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const totalSize = parseInt(res.headers["content-length"] || "0", 10);
|
|
126
|
+
let downloaded = 0;
|
|
127
|
+
|
|
128
|
+
const writeStream = fs.createWriteStream(filepath);
|
|
129
|
+
|
|
130
|
+
// Progress bar
|
|
131
|
+
res.on("data", (chunk) => {
|
|
132
|
+
downloaded += chunk.length;
|
|
133
|
+
|
|
134
|
+
if (totalSize > 0) {
|
|
135
|
+
const pct = (downloaded / totalSize) * 100;
|
|
136
|
+
const barLength = 30;
|
|
137
|
+
const filled = Math.round((pct / 100) * barLength);
|
|
138
|
+
const bar = "█".repeat(filled) + "░".repeat(barLength - filled);
|
|
139
|
+
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`\r${bar} ${pct.toFixed(1)}% (${(downloaded / 1e6).toFixed(2)}MB/${(totalSize / 1e6).toFixed(2)}MB)`
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
process.stdout.write(`\rDownloaded ${(downloaded / 1e6).toFixed(2)}MB`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
res.on("end", () => process.stdout.write("\n"));
|
|
149
|
+
|
|
150
|
+
res.pipe(writeStream);
|
|
151
|
+
|
|
152
|
+
writeStream.on("finish", () => writeStream.close(resolve));
|
|
153
|
+
|
|
154
|
+
writeStream.on("error", (err) => {
|
|
155
|
+
fs.unlink(filepath, () => reject(err));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// -----------------------------
|
|
160
|
+
// Add timeout here
|
|
161
|
+
// -----------------------------
|
|
162
|
+
const timeoutMs = 5000; // 5 seconds
|
|
163
|
+
req.setTimeout(timeoutMs, () => {
|
|
164
|
+
req.abort();
|
|
165
|
+
// Throw a special error for retry logic
|
|
166
|
+
reject(new Error("DOWNLOAD_TIMEOUT"));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Handle request errors
|
|
170
|
+
req.on("error", (err) => {
|
|
171
|
+
if (err.code === "ECONNRESET") {
|
|
172
|
+
reject(new Error("DOWNLOAD_TIMEOUT"));
|
|
173
|
+
} else {
|
|
174
|
+
reject(err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// begin download
|
|
180
|
+
startDownload(url);
|
|
181
|
+
});
|
|
182
|
+
}, {
|
|
183
|
+
retries: 3, // retry up to 3 times
|
|
184
|
+
onRetry: (err, attempt) => {
|
|
185
|
+
if (err.message === "DOWNLOAD_TIMEOUT") {
|
|
186
|
+
this.logger.warn(`Download timed out. Retrying attempt ${attempt}...`);
|
|
187
|
+
} else {
|
|
188
|
+
this.logger.warn(`Download failed: ${err.message}. Retrying attempt ${attempt}...`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
sanitizeFilename(name, fallback) {
|
|
196
|
+
if (!name) return fallback;
|
|
197
|
+
return name
|
|
198
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
199
|
+
.replace(/_+/g, "_")
|
|
200
|
+
.substring(0, 180);
|
|
201
|
+
}
|
|
202
|
+
|
|
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) {
|
|
217
|
+
const objectId = e.objectId;
|
|
218
|
+
const objectName = R.path(["metadata", "public", "name"], e) || objectId;
|
|
219
|
+
|
|
220
|
+
this.logger.log(`\n--- Processing ${objectName} (${objectId}) ---`);
|
|
221
|
+
|
|
222
|
+
const formattedObj = { object_id: objectId, name: objectName };
|
|
223
|
+
|
|
224
|
+
// Skip existing
|
|
225
|
+
const existing = fs.readdirSync(targetDir).find((f) => f.includes(objectId));
|
|
226
|
+
if (existing) {
|
|
227
|
+
this.logger.log(`Skipping ${objectName}: already exists (${existing})`);
|
|
228
|
+
formattedObj.download_url = "SKIPPED_ALREADY_EXISTS";
|
|
229
|
+
return formattedObj;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
// Version hash
|
|
234
|
+
const versionHash = await this.retry(() =>
|
|
235
|
+
this.concerns.FabricObject.latestVersionHash({ libraryId, objectId })
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Start job
|
|
239
|
+
const response = await this.retry(() =>
|
|
240
|
+
client.MakeFileServiceRequest({
|
|
241
|
+
versionHash,
|
|
242
|
+
path: "/call/media/files",
|
|
243
|
+
method: "POST",
|
|
244
|
+
body: { format, offering },
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const jobId = response.job_id;
|
|
249
|
+
this.logger.log(`Started job ${jobId}`);
|
|
250
|
+
|
|
251
|
+
// Poll job
|
|
252
|
+
let status;
|
|
253
|
+
let lastProgress = -1;
|
|
254
|
+
const maxPolls = 300; // 10 minutes at 2s intervals
|
|
255
|
+
let pollCount = 0;
|
|
256
|
+
|
|
257
|
+
do {
|
|
258
|
+
await this.sleep(2000);
|
|
259
|
+
|
|
260
|
+
status = await this.retry(() =>
|
|
261
|
+
client.MakeFileServiceRequest({
|
|
262
|
+
versionHash,
|
|
263
|
+
path: `/call/media/files/${jobId}`,
|
|
264
|
+
})
|
|
265
|
+
);
|
|
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
|
+
|
|
277
|
+
const progress = status?.progress || 0;
|
|
278
|
+
if (progress !== lastProgress) {
|
|
279
|
+
process.stdout.write(`Progress: ${progress.toFixed(1)}%\r`);
|
|
280
|
+
lastProgress = progress;
|
|
281
|
+
}
|
|
282
|
+
} while (status?.status !== "completed");
|
|
283
|
+
|
|
284
|
+
process.stdout.write("\n");
|
|
285
|
+
|
|
286
|
+
const filename = this.sanitizeFilename(
|
|
287
|
+
status.filename,
|
|
288
|
+
`${objectId}.mp4`
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const outputFile = path.join(targetDir, filename);
|
|
292
|
+
|
|
293
|
+
const downloadUrl = await this.retry(() =>
|
|
294
|
+
client.FabricUrl({
|
|
295
|
+
versionHash,
|
|
296
|
+
call: `/media/files/${jobId}/download`,
|
|
297
|
+
service: "files",
|
|
298
|
+
queryParams: {
|
|
299
|
+
"header-x_set_content_disposition": `attachment; filename=${filename}`,
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
formattedObj.download_url = downloadUrl;
|
|
305
|
+
|
|
306
|
+
if (fs.existsSync(outputFile)) {
|
|
307
|
+
this.logger.log(`Skipping ${objectName}: already exists (${filename})`);
|
|
308
|
+
return formattedObj;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// NOW using HTTPS downloader
|
|
312
|
+
this.logger.log(`Downloading → ${outputFile}`);
|
|
313
|
+
await this.downloadFile(downloadUrl, outputFile);
|
|
314
|
+
|
|
315
|
+
this.logger.log(`✔ Completed: ${outputFile}`);
|
|
316
|
+
return formattedObj;
|
|
317
|
+
|
|
318
|
+
} catch (err) {
|
|
319
|
+
this.logger.error(`FAILED: ${objectId} - ${err.message}`);
|
|
320
|
+
|
|
321
|
+
const fileServiceUrl = client.FileServiceHttpClient?.uris?.[client.FileServiceHttpClient.uriIndex] || "unknown";
|
|
322
|
+
|
|
323
|
+
const failEntry = {
|
|
324
|
+
object_id: objectId,
|
|
325
|
+
name: objectName,
|
|
326
|
+
error: err.message,
|
|
327
|
+
file_service_url: fileServiceUrl,
|
|
328
|
+
timestamp: new Date().toISOString(),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
failedDownloads.push(failEntry);
|
|
332
|
+
|
|
333
|
+
if (failLogPath) {
|
|
334
|
+
this.appendFailEntry(failLogPath, failEntry);
|
|
335
|
+
this.logger.warn(`Failure recorded → ${failLogPath}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return formattedObj;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async body() {
|
|
343
|
+
const libraryId = this.args.libraryId;
|
|
344
|
+
const format = this.args.format || "mp4";
|
|
345
|
+
const offering = this.args.offering || "default";
|
|
346
|
+
|
|
347
|
+
const filter =
|
|
348
|
+
this.args.filter &&
|
|
349
|
+
this.concerns.JSON.parseStringOrFile({ strOrPath: this.args.filter });
|
|
350
|
+
|
|
351
|
+
if (!this.args.fields) this.args.fields = [];
|
|
352
|
+
|
|
353
|
+
const select = ["/public/name", ...this.args.fields];
|
|
354
|
+
|
|
355
|
+
let objectList = await this.concerns.ArgLibraryId.libObjectList({
|
|
356
|
+
filterOptions: { select, filter },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
this.logger.log(`Found ${objectList.length} object(s)\n`);
|
|
360
|
+
|
|
361
|
+
const client = await this.concerns.Client.get();
|
|
362
|
+
const failedDownloads = [];
|
|
363
|
+
|
|
364
|
+
const targetDir = this.args.downloadDir
|
|
365
|
+
? path.resolve(this.args.downloadDir)
|
|
366
|
+
: process.cwd();
|
|
367
|
+
|
|
368
|
+
if (!fs.existsSync(targetDir))
|
|
369
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
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
|
+
|
|
378
|
+
// Sequential downloads
|
|
379
|
+
const results = [];
|
|
380
|
+
for (const obj of objectList) {
|
|
381
|
+
const r = await this.processObject(
|
|
382
|
+
obj,
|
|
383
|
+
client,
|
|
384
|
+
libraryId,
|
|
385
|
+
format,
|
|
386
|
+
offering,
|
|
387
|
+
targetDir,
|
|
388
|
+
failedDownloads,
|
|
389
|
+
failLogPath
|
|
390
|
+
);
|
|
391
|
+
results.push(r);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Summary
|
|
395
|
+
this.logger.log("\n=== SUMMARY ===");
|
|
396
|
+
this.logger.log(`Processed: ${objectList.length}`);
|
|
397
|
+
this.logger.log(`Successful: ${results.length - failedDownloads.length}`);
|
|
398
|
+
this.logger.log(`Failed: ${failedDownloads.length}`);
|
|
399
|
+
|
|
400
|
+
if (failedDownloads.length > 0) {
|
|
401
|
+
this.logger.warn("\n=== FAILED DOWNLOADS ===");
|
|
402
|
+
this.logger.logTable({ list: failedDownloads });
|
|
403
|
+
|
|
404
|
+
if (failLogPath) {
|
|
405
|
+
this.logger.warn(`Full failure log: ${failLogPath}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { results, failedDownloads };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (require.main === module) {
|
|
414
|
+
Utility.cmdLineInvoke(LibraryDownloadMp4);
|
|
415
|
+
} else {
|
|
416
|
+
module.exports = LibraryDownloadMp4;
|
|
417
|
+
}
|