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 +1 -1
- package/src/storage/allocator.js +44 -22
- package/src/storage/archive.js +1 -1
- package/src/storage/rclone.js +52 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.1.
|
|
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",
|
package/src/storage/allocator.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
126
|
-
if (typeof onProgress === 'function') onProgress({ done, total:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
const
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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.
|
package/src/storage/archive.js
CHANGED
|
@@ -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;
|
package/src/storage/rclone.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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() };
|