@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.
@@ -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
+ }