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 +1 -1
- package/src/storage/allocator.js +25 -19
- package/src/storage/rclone.js +44 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.1.
|
|
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",
|
package/src/storage/allocator.js
CHANGED
|
@@ -122,12 +122,12 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
122
122
|
entries: [],
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
-
const
|
|
126
|
-
if (typeof onProgress === 'function') onProgress({ done, total:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
const
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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.
|
package/src/storage/rclone.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|