parrot-blackbox 2.1.0 → 2.1.2

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.2",
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",
@@ -65,7 +65,11 @@ export function walkFiles(localDir) {
65
65
  * will Google Drive accounts be used.
66
66
  * 2. Within each provider tier the classic water-filling strategy applies:
67
67
  * pick the account that results in the lowest used-percentage after
68
- * placing the file (most headroom wins on ties).
68
+ * placing the file. Accounts that are within ~0.5% of quota from each
69
+ * other are treated as equally full, and the account EARLIEST in the
70
+ * configured pool order wins the tie — so an all-empty pool fills in
71
+ * account order (mega-1, mega-2, …) instead of chasing `rclone about`
72
+ * usage noise.
69
73
  */
70
74
  export function chooseAccount(needed, accounts) {
71
75
  // Tier 0 = mega, Tier 1 = gdrive / everything else.
@@ -79,14 +83,26 @@ export function chooseAccount(needed, accounts) {
79
83
  const candidates = eligible.filter((a) => providerTier(a) === bestTier);
80
84
 
81
85
  // Water-fill within the chosen tier: minimise resulting used-percentage.
86
+ // SCORE_EPS absorbs `rclone about` usage noise — MEGA inconsistently reports
87
+ // a few bytes to tens-of-MiB of phantom usage on otherwise-empty accounts.
88
+ // As long as accounts are effectively equally full, files go onto the account
89
+ // EARLIEST in the configured pool order.
90
+ const SCORE_EPS = 0.005; // 0.5 percentage points of quota
82
91
  let best = null;
83
92
  let bestScore = Infinity;
84
- for (const acc of candidates) {
93
+ let bestIdx = Infinity;
94
+ for (let i = 0; i < candidates.length; i++) {
95
+ const acc = candidates[i];
85
96
  const quota = acc.total || 0;
86
97
  const score = quota ? (acc.used + needed) / quota : needed;
87
- if (score < bestScore || (score === bestScore && acc.free > (best?.free ?? -1))) {
98
+ if (score < bestScore - SCORE_EPS) {
88
99
  bestScore = score;
89
100
  best = acc;
101
+ bestIdx = i;
102
+ } else if (Math.abs(score - bestScore) <= SCORE_EPS && i < bestIdx) {
103
+ // Effectively a tie → the earlier account in the pool wins.
104
+ best = acc;
105
+ bestIdx = i;
90
106
  }
91
107
  }
92
108
  return best;
@@ -122,12 +138,12 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
122
138
  entries: [],
123
139
  };
124
140
 
125
- const report = (done, text) => {
126
- if (typeof onProgress === 'function') onProgress({ done, total: files.length, text });
141
+ const emit = (done, text, remote) => {
142
+ if (typeof onProgress === 'function') onProgress({ done, total: totalSize, text, remote });
127
143
  };
128
144
 
129
145
  // PASS 1: Planning
130
- report(0, 'planning allocations...');
146
+ emit(0, 'planning allocations...');
131
147
  const batches = new Map(); // account.remote -> array of relative paths
132
148
  const splits = []; // files that need splitting
133
149
 
@@ -162,24 +178,30 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
162
178
 
163
179
  // PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
164
180
  const batchDir = process.env.PBB_STATE_DIR || '/tmp';
165
- let placed = 0;
166
-
181
+ const fileSizes = new Map(files.map((f) => [f.rel, f.size]));
182
+ const batchBytes = (rels) => rels.reduce((s, r) => s + (fileSizes.get(r) ?? 0), 0);
183
+ let placedBytes = 0;
184
+
167
185
  for (const [remote, batch] of batches.entries()) {
168
186
  if (batch.files.length === 0) continue;
169
187
  const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
170
188
  fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
171
-
189
+
172
190
  // Ensure the remote directory exists to prevent "directory not found" errors
173
191
  // when rclone tries to list the destination for --files-from checking.
174
192
  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);
193
+
194
+ const thisBatchBytes = batchBytes(batch.files);
195
+ const label = `uploading ${batch.files.length} file(s) → ${remote}`;
196
+ emit(placedBytes, label, remote);
197
+ const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath, {
198
+ onProgress: (p) => emit(placedBytes + p.done, label, remote),
199
+ });
178
200
  if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
179
-
201
+
180
202
  fs.unlinkSync(batchPath);
181
- placed += batch.files.length;
182
- report(placed, `placed ${placed}/${files.length}`);
203
+ placedBytes += thisBatchBytes;
204
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, remote);
183
205
  }
184
206
 
185
207
  // PASS 3: Split large files
@@ -189,27 +211,27 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
189
211
  const locs = [];
190
212
  let start = 0;
191
213
  let partIndex = 0;
192
-
193
- report(placed, `splitting huge file ${rel}...`);
214
+
215
+ emit(placedBytes, `splitting huge file ${rel}...`);
194
216
  while (start < entry.size) {
195
217
  const len = Math.min(chunkSize, entry.size - start);
196
218
  const acc = chooseAccount(len, pool);
197
219
  if (!acc) throw outOfSpace();
198
-
220
+
199
221
  const partAbs = await makePartFile(entry, start, len);
200
222
  const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
201
-
223
+
202
224
  const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
203
225
  if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
204
-
226
+
205
227
  consume(acc, len);
206
228
  locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
207
229
  start += len;
208
230
  partIndex += 1;
231
+ placedBytes += len;
232
+ emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, acc.remote);
209
233
  }
210
234
  manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
211
- placed += 1;
212
- report(placed, `placed ${placed}/${files.length}`);
213
235
  }
214
236
 
215
237
  // Manifest: cloud + local mirror.
@@ -150,7 +150,7 @@ export async function restoreArtifact(manifest, destDir, { onProgress } = {}) {
150
150
  for (const entry of batch.entries) {
151
151
  const target = path.join(destDir, ...entry.rel.split('/'));
152
152
  const loc = entry.loc[0];
153
- const r = await copyToFile(`${loc.remote}:${loc.path}`, target);
153
+ const r = await copyToFile(`${loc.remote}:${loc.path}`, target, { force: true });
154
154
  if (!r.ok) throw new Error(`download failed for ${entry.rel}: ${r.error}`);
155
155
  files += 1;
156
156
  bytes += entry.size;
@@ -64,26 +64,67 @@ export async function copyDir(localDir, remotePath) {
64
64
  }
65
65
 
66
66
  /** Copy a single local file to an exact remote path. */
67
- export async function copyToFile(localFile, remotePath, { ignoreExisting = false } = {}) {
67
+ export async function copyToFile(localFile, remotePath, { ignoreExisting = false, force = false } = {}) {
68
68
  const args = ['copyto', localFile, remotePath];
69
69
  if (ignoreExisting) args.push('--ignore-existing');
70
+ if (force) args.push('--ignore-times'); // overwrite even a "newer" destination
70
71
  const res = await rejectFalse(args);
71
72
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
72
73
  }
73
74
 
74
- /** Copy a batch of files using --files-from. */
75
- export async function copyBatch(localDir, remotePath, filesFromPath) {
75
+ /** Copy a batch of files using --files-from. When `onProgress` is supplied we
76
+ * ask rclone for live transfer stats and parse its `Transferred: X / Y` frames
77
+ * so the caller receives byte-level progress as `{ done, total }` (bytes). */
78
+ export async function copyBatch(localDir, remotePath, filesFromPath, { onProgress } = {}) {
76
79
  const args = [
77
80
  'copy', localDir, remotePath,
78
81
  '--files-from', filesFromPath,
79
82
  '--transfers=16',
80
83
  '--checkers=16',
81
- '--fast-list'
84
+ '--fast-list',
82
85
  ];
83
- const res = await rejectFalse(args);
86
+ if (typeof onProgress === 'function') {
87
+ // rclone emits periodic frames even when stdout is not a TTY (pipelines).
88
+ args.push('--progress', '--stats=1s');
89
+ }
90
+ const child = rejectFalse(args);
91
+ if (typeof onProgress === 'function') {
92
+ await consumeProgress(child, onProgress);
93
+ }
94
+ const res = await child;
84
95
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
85
96
  }
86
97
 
98
+ /** Consume rclone's --progress stdout, emitting parsed byte counts per frame. */
99
+ async function consumeProgress(child, onProgress) {
100
+ let buf = '';
101
+ for await (const chunk of child.stdout) {
102
+ buf += chunk;
103
+ const frames = buf.split(/[\r\n]+/);
104
+ buf = frames.pop(); // keep a partial trailing frame for the next chunk
105
+ for (const frame of frames) {
106
+ const p = parseTransferFrame(frame);
107
+ if (p) onProgress(p);
108
+ }
109
+ }
110
+ const tail = parseTransferFrame(buf);
111
+ if (tail) onProgress(tail);
112
+ }
113
+
114
+ /**
115
+ * Extract the BYTE `Transferred: <done> / <total>` from an rclone progress
116
+ * frame. This deliberately requires a size unit (KiB/MiB/GiB/…) so the separate
117
+ * FILE-count `Transferred: 3 / 5` line is never mistaken for bytes.
118
+ * @returns {{done:number,total:number}|null}
119
+ */
120
+ export function parseTransferFrame(frame) {
121
+ const m = /\bTransferred:\s+([\d.]+\s*(?:[kmgt]i?)?b)\s*\/\s*([\d.]+\s*(?:[kmgt]i?)?b)/i.exec(frame);
122
+ if (!m) return null;
123
+ const done = parseBytes(m[1]);
124
+ const total = parseBytes(m[2]);
125
+ return done !== null && total !== null ? { done, total } : null;
126
+ }
127
+
87
128
  /** Download a batch of files using --files-from (reverse of copyBatch). */
88
129
  export async function downloadBatch(remotePath, localDir, filesFromPath) {
89
130
  const args = [
@@ -91,7 +132,12 @@ export async function downloadBatch(remotePath, localDir, filesFromPath) {
91
132
  '--files-from', filesFromPath,
92
133
  '--transfers=16',
93
134
  '--checkers=16',
94
- '--fast-list'
135
+ '--fast-list',
136
+ // Restore MUST reproduce the artifact exactly. rclone's default skips a
137
+ // destination file whose mtime looks newer (exactly what fresh-install
138
+ // default files are), which silently leaves "conflicts" — your backed-up
139
+ // version never comes back. --ignore-times forces the overwrite.
140
+ '--ignore-times',
95
141
  ];
96
142
  const res = await rejectFalse(args);
97
143
  return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };