parrot-blackbox 2.1.0 → 2.1.1

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": "parrot-blackbox",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
@@ -122,12 +122,12 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
122
122
  entries: [],
123
123
  };
124
124
 
125
- const report = (done, text) => {
126
- if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
125
+ const emit = (done, text, remote) => {
126
+ if (typeof onProgress === 'function') onProgress({ done, total: totalSize, text, remote });
127
127
  };
128
128
 
129
129
  // PASS 1: Planning
130
- report(0, 'planning allocations...');
130
+ emit(0, 'planning allocations...');
131
131
  const batches = new Map(); // account.remote -> array of relative paths
132
132
  const splits = []; // files that need splitting
133
133
 
@@ -162,24 +162,30 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
162
162
 
163
163
  // PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
164
164
  const batchDir = process.env.PBB_STATE_DIR || '/tmp';
165
- let placed = 0;
166
-
165
+ const fileSizes = new Map(files.map((f) => [f.rel, f.size]));
166
+ const batchBytes = (rels) => rels.reduce((s, r) => s + (fileSizes.get(r) ?? 0), 0);
167
+ let placedBytes = 0;
168
+
167
169
  for (const [remote, batch] of batches.entries()) {
168
170
  if (batch.files.length === 0) continue;
169
171
  const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
170
172
  fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
171
-
173
+
172
174
  // Ensure the remote directory exists to prevent "directory not found" errors
173
175
  // when rclone tries to list the destination for --files-from checking.
174
176
  await mkdirRemote(`${remote}:${basePath}`);
175
-
176
- report(placed, `uploading batch of ${batch.files.length} files to ${remote}...`);
177
- const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath);
177
+
178
+ const thisBatchBytes = batchBytes(batch.files);
179
+ const label = `uploading ${batch.files.length} file(s) → ${remote}`;
180
+ emit(placedBytes, label, remote);
181
+ const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath, {
182
+ onProgress: (p) => emit(placedBytes + p.done, label, remote),
183
+ });
178
184
  if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
179
-
185
+
180
186
  fs.unlinkSync(batchPath);
181
- placed += batch.files.length;
182
- report(placed, `placed ${placed}/${files.length}`);
187
+ placedBytes += thisBatchBytes;
188
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, remote);
183
189
  }
184
190
 
185
191
  // PASS 3: Split large files
@@ -189,27 +195,27 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
189
195
  const locs = [];
190
196
  let start = 0;
191
197
  let partIndex = 0;
192
-
193
- report(placed, `splitting huge file ${rel}...`);
198
+
199
+ emit(placedBytes, `splitting huge file ${rel}...`);
194
200
  while (start < entry.size) {
195
201
  const len = Math.min(chunkSize, entry.size - start);
196
202
  const acc = chooseAccount(len, pool);
197
203
  if (!acc) throw outOfSpace();
198
-
204
+
199
205
  const partAbs = await makePartFile(entry, start, len);
200
206
  const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
201
-
207
+
202
208
  const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
203
209
  if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
204
-
210
+
205
211
  consume(acc, len);
206
212
  locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
207
213
  start += len;
208
214
  partIndex += 1;
215
+ placedBytes += len;
216
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, acc.remote);
209
217
  }
210
218
  manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
211
- placed += 1;
212
- report(placed, `placed ${placed}/${files.length}`);
213
219
  }
214
220
 
215
221
  // Manifest: cloud + local mirror.
@@ -71,19 +71,59 @@ export async function copyToFile(localFile, remotePath, { ignoreExisting = false
71
71
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
72
72
  }
73
73
 
74
- /** Copy a batch of files using --files-from. */
75
- export async function copyBatch(localDir, remotePath, filesFromPath) {
74
+ /** Copy a batch of files using --files-from. When `onProgress` is supplied we
75
+ * ask rclone for live transfer stats and parse its `Transferred: X / Y` frames
76
+ * so the caller receives byte-level progress as `{ done, total }` (bytes). */
77
+ export async function copyBatch(localDir, remotePath, filesFromPath, { onProgress } = {}) {
76
78
  const args = [
77
79
  'copy', localDir, remotePath,
78
80
  '--files-from', filesFromPath,
79
81
  '--transfers=16',
80
82
  '--checkers=16',
81
- '--fast-list'
83
+ '--fast-list',
82
84
  ];
83
- const res = await rejectFalse(args);
85
+ if (typeof onProgress === 'function') {
86
+ // rclone emits periodic frames even when stdout is not a TTY (pipelines).
87
+ args.push('--progress', '--stats=1s');
88
+ }
89
+ const child = rejectFalse(args);
90
+ if (typeof onProgress === 'function') {
91
+ await consumeProgress(child, onProgress);
92
+ }
93
+ const res = await child;
84
94
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
85
95
  }
86
96
 
97
+ /** Consume rclone's --progress stdout, emitting parsed byte counts per frame. */
98
+ async function consumeProgress(child, onProgress) {
99
+ let buf = '';
100
+ for await (const chunk of child.stdout) {
101
+ buf += chunk;
102
+ const frames = buf.split(/[\r\n]+/);
103
+ buf = frames.pop(); // keep a partial trailing frame for the next chunk
104
+ for (const frame of frames) {
105
+ const p = parseTransferFrame(frame);
106
+ if (p) onProgress(p);
107
+ }
108
+ }
109
+ const tail = parseTransferFrame(buf);
110
+ if (tail) onProgress(tail);
111
+ }
112
+
113
+ /**
114
+ * Extract the BYTE `Transferred: <done> / <total>` from an rclone progress
115
+ * frame. This deliberately requires a size unit (KiB/MiB/GiB/…) so the separate
116
+ * FILE-count `Transferred: 3 / 5` line is never mistaken for bytes.
117
+ * @returns {{done:number,total:number}|null}
118
+ */
119
+ export function parseTransferFrame(frame) {
120
+ const m = /\bTransferred:\s+([\d.]+\s*(?:[kmgt]i?)?b)\s*\/\s*([\d.]+\s*(?:[kmgt]i?)?b)/i.exec(frame);
121
+ if (!m) return null;
122
+ const done = parseBytes(m[1]);
123
+ const total = parseBytes(m[2]);
124
+ return done !== null && total !== null ? { done, total } : null;
125
+ }
126
+
87
127
  /** Download a batch of files using --files-from (reverse of copyBatch). */
88
128
  export async function downloadBatch(remotePath, localDir, filesFromPath) {
89
129
  const args = [