data-ark 0.1.1 → 0.1.3
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/README.md +45 -3
- package/bin/data-ark.js +25 -16
- package/package.json +1 -1
- package/src/caption.js +63 -0
- package/src/chunking.js +6 -0
- package/src/cli.js +65 -3
- package/src/client.js +78 -2
- package/src/commands/list.js +136 -0
- package/src/commands/login.js +25 -7
- package/src/commands/restore.js +41 -35
- package/src/commands/set-destination.js +15 -0
- package/src/commands/status.js +93 -0
- package/src/commands/upload.js +77 -29
- package/src/config.js +9 -4
- package/src/downloader.js +42 -6
- package/src/manifest.js +3 -1
- package/src/progress.js +7 -1
- package/src/state.js +76 -10
- package/src/uploader.js +4 -1
package/src/commands/restore.js
CHANGED
|
@@ -3,32 +3,21 @@ import path from 'node:path'
|
|
|
3
3
|
import readline from 'node:readline/promises'
|
|
4
4
|
import { stdin, stdout } from 'node:process'
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import { connect as realConnect, requireChat } from '../client.js'
|
|
6
|
+
import { closeQuietly, connect as realConnect, requireChat, searchDocuments } from '../client.js'
|
|
9
7
|
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
10
8
|
import { downloadToFile } from '../downloader.js'
|
|
11
9
|
import { manifestFileName, parseManifest } from '../manifest.js'
|
|
12
|
-
import { createProgress, formatBytes } from '../progress.js'
|
|
10
|
+
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return named?.fileName ?? null
|
|
18
|
-
}
|
|
12
|
+
// Anything past a minute of waiting needs saying out loud; below that the pause is shorter
|
|
13
|
+
// than the time a user would spend wondering about it.
|
|
14
|
+
const LONG_WAIT_MS = 60_000
|
|
19
15
|
|
|
20
16
|
async function realSearchManifest(client, peer, backupId) {
|
|
21
17
|
const wanted = manifestFileName(backupId)
|
|
18
|
+
const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
// hashes and pagination itself, so we don't hand-build easily mistyped fields.
|
|
25
|
-
const messages = await client.getMessages(peer, {
|
|
26
|
-
search: backupId,
|
|
27
|
-
filter: new Api.InputMessagesFilterDocument(),
|
|
28
|
-
limit: 100,
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
return messages.find((m) => documentFileName(m) === wanted) ?? null
|
|
20
|
+
return found.find((doc) => doc.fileName === wanted)?.message ?? null
|
|
32
21
|
}
|
|
33
22
|
|
|
34
23
|
async function realReadMessageBytes(client, message) {
|
|
@@ -40,8 +29,8 @@ async function realGetMessage(client, peer, msgId) {
|
|
|
40
29
|
return message ?? null
|
|
41
30
|
}
|
|
42
31
|
|
|
43
|
-
export async function realDownloadChunk(client, message, handle, offset, onProgress) {
|
|
44
|
-
return await downloadToFile(client, message, handle.fd, { offset, onProgress })
|
|
32
|
+
export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
|
|
33
|
+
return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
|
|
45
34
|
}
|
|
46
35
|
|
|
47
36
|
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
@@ -78,16 +67,36 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
78
67
|
getMessage = realGetMessage,
|
|
79
68
|
downloadChunk = realDownloadChunk,
|
|
80
69
|
confirm = askConfirm,
|
|
70
|
+
retryOptions = {},
|
|
81
71
|
writeErr = (line) => process.stderr.write(line),
|
|
72
|
+
log: writeLog = (line) => console.log(line),
|
|
82
73
|
silent = false,
|
|
83
74
|
} = deps
|
|
84
75
|
|
|
85
76
|
const config = await loadConfig(configDir)
|
|
86
77
|
const chat = requireChat(options, config)
|
|
87
|
-
const log = silent ? () => {} :
|
|
78
|
+
const log = silent ? () => {} : writeLog
|
|
88
79
|
const warn = silent ? () => {} : writeErr
|
|
89
80
|
|
|
90
|
-
|
|
81
|
+
// A restore keeps no progress file, so a part that comes back -503 is retried rather than
|
|
82
|
+
// thrown away — and a retry nobody is told about is indistinguishable from a hung transfer,
|
|
83
|
+
// because the progress bar simply stops moving while the wait runs.
|
|
84
|
+
function onRetry(err, attempt, delayMs) {
|
|
85
|
+
if (delayMs > LONG_WAIT_MS) {
|
|
86
|
+
warn(
|
|
87
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
|
|
88
|
+
`(${err.message}). data-ark is waiting and will carry on by itself, leave it running.\n`,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
warn(
|
|
94
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
95
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const client = await connect(config, { verbose: options.verbose })
|
|
91
100
|
|
|
92
101
|
try {
|
|
93
102
|
const manifestMessage = await searchManifest(client, chat, backupId)
|
|
@@ -136,13 +145,12 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
136
145
|
)
|
|
137
146
|
}
|
|
138
147
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})
|
|
148
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
149
|
+
const progress = createProgress({
|
|
150
|
+
total: chunk.size,
|
|
151
|
+
label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
|
|
152
|
+
write: warn,
|
|
153
|
+
})
|
|
146
154
|
|
|
147
155
|
const { sha256, size } = await downloadChunk(
|
|
148
156
|
client,
|
|
@@ -150,6 +158,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
150
158
|
handle,
|
|
151
159
|
chunk.i * manifest.chunkSize,
|
|
152
160
|
progress.advance,
|
|
161
|
+
{ ...retryOptions, onRetry },
|
|
153
162
|
)
|
|
154
163
|
|
|
155
164
|
progress.finish()
|
|
@@ -188,11 +197,8 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
188
197
|
|
|
189
198
|
return { path: target, size: manifest.size }
|
|
190
199
|
} finally {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
} catch (err) {
|
|
195
|
-
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
|
|
196
|
-
}
|
|
200
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
201
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
202
|
+
)
|
|
197
203
|
}
|
|
198
204
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { normalizeChatTarget } from '../client.js'
|
|
2
|
+
import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
|
|
3
|
+
|
|
4
|
+
export async function runSetDestination(options = {}, deps = {}) {
|
|
5
|
+
const { configDir = defaultConfigDir(), log = (line) => console.log(line) } = deps
|
|
6
|
+
|
|
7
|
+
// Normalize before touching the config: an unusable destination must not be written,
|
|
8
|
+
// or the next upload fails with an error about a value the user never really set.
|
|
9
|
+
const chat = normalizeChatTarget(options.to)
|
|
10
|
+
const config = await loadConfig(configDir)
|
|
11
|
+
|
|
12
|
+
await saveConfig({ ...config, defaultChat: String(chat) }, configDir)
|
|
13
|
+
|
|
14
|
+
log(`Destination set to ${chat}. It will be used for the next upload.`)
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { countChunks } from '../chunking.js'
|
|
4
|
+
import { assertLoggedIn, closeQuietly, connect as realConnect, describeChat } from '../client.js'
|
|
5
|
+
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
6
|
+
import { runSetDestination } from './set-destination.js'
|
|
7
|
+
import { formatBytes } from '../progress.js'
|
|
8
|
+
import { listStates } from '../state.js'
|
|
9
|
+
|
|
10
|
+
const LABEL_WIDTH = 'Destination'.length + 2
|
|
11
|
+
|
|
12
|
+
function row(label, value) {
|
|
13
|
+
return `${label.padEnd(LABEL_WIDTH)}${value}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function describeAccount(me) {
|
|
17
|
+
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
18
|
+
const handle = me.username ? ` (@${me.username})` : ''
|
|
19
|
+
|
|
20
|
+
return `${name || me.username || me.phone || 'unknown'}${handle}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The account line is the only part that needs Telegram, and it is also the only part that
|
|
24
|
+
// can fail. Whatever it costs, it must not take the rest of the report down with it: the
|
|
25
|
+
// unfinished backups are exactly what someone runs status to see after a session expires.
|
|
26
|
+
async function accountLine(config, options, deps) {
|
|
27
|
+
const { connect, disconnect } = deps
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
assertLoggedIn(config)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return err.message
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let client
|
|
36
|
+
try {
|
|
37
|
+
client = await connect(config, { verbose: options.verbose })
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return err.message
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return describeAccount(await client.getMe())
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return `could not be read: ${err.message}`
|
|
46
|
+
} finally {
|
|
47
|
+
await closeQuietly(client, disconnect)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runStatus(options = {}, deps = {}) {
|
|
52
|
+
const {
|
|
53
|
+
configDir = defaultConfigDir(),
|
|
54
|
+
connect = realConnect,
|
|
55
|
+
disconnect = (client) => client.destroy(),
|
|
56
|
+
log = (line) => console.log(line),
|
|
57
|
+
} = deps
|
|
58
|
+
|
|
59
|
+
// `status --to @chan` reads as one request: point somewhere new, then show me where I am.
|
|
60
|
+
// Setting it first is also what makes the Destination line below tell the current truth.
|
|
61
|
+
if (options.to) {
|
|
62
|
+
await runSetDestination(options, { configDir, log: () => {} })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config = await loadConfig(configDir)
|
|
66
|
+
|
|
67
|
+
log(row('Account', await accountLine(config, options, { connect, disconnect })))
|
|
68
|
+
log(
|
|
69
|
+
row(
|
|
70
|
+
'Destination',
|
|
71
|
+
config.defaultChat
|
|
72
|
+
? describeChat(config.defaultChat)
|
|
73
|
+
: 'none set — pass --to @my_backups to set one',
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const states = await listStates(configDir)
|
|
78
|
+
|
|
79
|
+
if (states.length === 0) {
|
|
80
|
+
log(row('Unfinished', 'none'))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
|
|
85
|
+
|
|
86
|
+
for (const state of states) {
|
|
87
|
+
const total = countChunks(state.size, state.chunkSize)
|
|
88
|
+
const done = Object.keys(state.done ?? {}).length
|
|
89
|
+
|
|
90
|
+
log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
|
|
91
|
+
log(` → ${describeChat(state.chat)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/commands/upload.js
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
parseSize,
|
|
13
13
|
planChunks,
|
|
14
14
|
} from '../chunking.js'
|
|
15
|
-
import {
|
|
15
|
+
import { chunkCaption, manifestCaption } from '../caption.js'
|
|
16
|
+
import { closeQuietly, connect as realConnect, describeChat, requireChat } from '../client.js'
|
|
16
17
|
import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
|
|
17
18
|
import {
|
|
18
19
|
buildManifest,
|
|
@@ -22,27 +23,41 @@ import {
|
|
|
22
23
|
serializeManifest,
|
|
23
24
|
} from '../manifest.js'
|
|
24
25
|
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
MAX_STATES,
|
|
28
|
+
clearState,
|
|
29
|
+
loadState,
|
|
30
|
+
markChunkDone,
|
|
31
|
+
pruneStates,
|
|
32
|
+
saveState,
|
|
33
|
+
stateFile,
|
|
34
|
+
stateKey,
|
|
35
|
+
} from '../state.js'
|
|
26
36
|
import { uploadRange } from '../uploader.js'
|
|
27
37
|
|
|
28
38
|
// Above this threshold the wait must be spelled out, per spec §8.
|
|
29
39
|
const LONG_WAIT_MS = 60_000
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
// Chunks and manifests differ only in where the bytes come from. Everything Telegram is
|
|
42
|
+
// told about them — document, not preview; this exact file name — is decided once.
|
|
43
|
+
async function sendDocument(client, peer, { file, fileName, caption }) {
|
|
32
44
|
return await client.sendFile(peer, {
|
|
33
|
-
file
|
|
45
|
+
file,
|
|
34
46
|
caption,
|
|
35
47
|
forceDocument: true,
|
|
36
48
|
attributes: [new Api.DocumentAttributeFilename({ fileName })],
|
|
37
49
|
})
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
|
|
53
|
+
return await sendDocument(client, peer, { file: inputFile, fileName, caption })
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
async function realSendManifest(client, peer, { bytes, fileName, caption }) {
|
|
41
|
-
return await client
|
|
57
|
+
return await sendDocument(client, peer, {
|
|
42
58
|
file: new CustomFile(fileName, bytes.length, '', bytes),
|
|
59
|
+
fileName,
|
|
43
60
|
caption,
|
|
44
|
-
forceDocument: true,
|
|
45
|
-
attributes: [new Api.DocumentAttributeFilename({ fileName })],
|
|
46
61
|
})
|
|
47
62
|
}
|
|
48
63
|
|
|
@@ -57,6 +72,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
57
72
|
retryOptions = {},
|
|
58
73
|
writeErr = (line) => process.stderr.write(line),
|
|
59
74
|
silent = false,
|
|
75
|
+
onBackupId = () => {},
|
|
60
76
|
} = deps
|
|
61
77
|
|
|
62
78
|
const absPath = path.resolve(filePath)
|
|
@@ -75,7 +91,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
75
91
|
|
|
76
92
|
const config = await loadConfig(configDir)
|
|
77
93
|
const chat = requireChat(options, config)
|
|
78
|
-
const
|
|
94
|
+
const requestedChunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : null
|
|
79
95
|
const concurrency = options.concurrency ? Number(options.concurrency) : DEFAULT_CONCURRENCY
|
|
80
96
|
|
|
81
97
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
|
|
@@ -86,18 +102,35 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
86
102
|
)
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
const chunks = planChunks(stat.size, chunkSize)
|
|
90
105
|
const key = stateKey(absPath, stat.size, stat.mtimeMs)
|
|
91
106
|
|
|
92
107
|
let state = await loadState(key, configDir)
|
|
93
|
-
|
|
108
|
+
|
|
109
|
+
// The chunks already in the chat were cut at the size this backup started with, and
|
|
110
|
+
// nothing can re-cut them. Carrying on at a different size would abandon every one of
|
|
111
|
+
// them in the chat, where data-ark can no longer find them — so an unfinished backup
|
|
112
|
+
// keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
|
|
113
|
+
if (state && requestedChunkSize !== null && requestedChunkSize !== state.chunkSize) {
|
|
114
|
+
const file = stateFile(key, configDir)
|
|
115
|
+
throw new Error(
|
|
116
|
+
`This unfinished backup is cut into ${formatBytes(state.chunkSize)} chunks, but ` +
|
|
117
|
+
`--chunk-size asks for ${formatBytes(requestedChunkSize)} — the chunks already in ` +
|
|
118
|
+
`${state.chat} cannot be re-cut. Run again without --chunk-size to carry on, or delete ` +
|
|
119
|
+
`${file} and run again to start a new backup, which leaves the chunks already sent ` +
|
|
120
|
+
'sitting in the chat with nothing to point at them.',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const chunkSize = state ? state.chunkSize : (requestedChunkSize ?? DEFAULT_CHUNK_SIZE)
|
|
125
|
+
const chunks = planChunks(stat.size, chunkSize)
|
|
126
|
+
const resuming = Boolean(state)
|
|
94
127
|
|
|
95
128
|
if (resuming && state.chat !== String(chat)) {
|
|
96
|
-
const
|
|
129
|
+
const file = stateFile(key, configDir)
|
|
97
130
|
throw new Error(
|
|
98
131
|
`This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
|
|
99
132
|
`a single backup cannot be split across two destinations. Run again without --to to keep ` +
|
|
100
|
-
`sending to ${state.chat}, or delete ${
|
|
133
|
+
`sending to ${state.chat}, or delete ${file} and run again to start a new backup in ${chat}.`,
|
|
101
134
|
)
|
|
102
135
|
}
|
|
103
136
|
|
|
@@ -116,8 +149,21 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
116
149
|
done: {},
|
|
117
150
|
}
|
|
118
151
|
await saveState(key, state, configDir)
|
|
152
|
+
|
|
153
|
+
// Only a new backup adds to the directory, so this is the one place it can grow.
|
|
154
|
+
// The report goes out even when the caller asked for silence: this is not narration
|
|
155
|
+
// about a transfer, it is data-ark dropping the only record of someone else's chunks.
|
|
156
|
+
for (const gone of await pruneStates(configDir)) {
|
|
157
|
+
writeErr(
|
|
158
|
+
`\nDropped the record of unfinished backup ${gone.id}: data-ark keeps the ` +
|
|
159
|
+
`${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
|
|
160
|
+
'searchable by that id, but that backup can no longer be resumed.\n',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
119
163
|
}
|
|
120
164
|
|
|
165
|
+
onBackupId(state.id)
|
|
166
|
+
|
|
121
167
|
const log = silent ? () => {} : (line) => console.log(line)
|
|
122
168
|
const warn = silent ? () => {} : writeErr
|
|
123
169
|
|
|
@@ -140,9 +186,9 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
140
186
|
|
|
141
187
|
log(`Backup ${state.id}`)
|
|
142
188
|
log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
|
|
143
|
-
log(`To ${chat}\n`)
|
|
189
|
+
log(`To ${describeChat(chat)}\n`)
|
|
144
190
|
|
|
145
|
-
const client = await connect(config)
|
|
191
|
+
const client = await connect(config, { verbose: options.verbose })
|
|
146
192
|
|
|
147
193
|
try {
|
|
148
194
|
for (const chunk of chunks) {
|
|
@@ -154,13 +200,12 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
154
200
|
const fileName = chunkFileName(state.id, chunk.i)
|
|
155
201
|
const handle = await fs.open(absPath, 'r')
|
|
156
202
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
})
|
|
203
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
204
|
+
const progress = createProgress({
|
|
205
|
+
total: chunk.length,
|
|
206
|
+
label: `Chunk ${chunk.i + 1}/${chunks.length}`,
|
|
207
|
+
write: warn,
|
|
208
|
+
})
|
|
164
209
|
|
|
165
210
|
try {
|
|
166
211
|
const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
|
|
@@ -178,7 +223,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
178
223
|
const message = await sendChunk(client, chat, {
|
|
179
224
|
inputFile,
|
|
180
225
|
fileName,
|
|
181
|
-
caption:
|
|
226
|
+
caption: chunkCaption({ id: state.id, number: chunk.i + 1, total: chunks.length }),
|
|
182
227
|
})
|
|
183
228
|
|
|
184
229
|
state = await markChunkDone(
|
|
@@ -218,7 +263,13 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
218
263
|
await sendManifest(client, chat, {
|
|
219
264
|
bytes: serializeManifest(manifest),
|
|
220
265
|
fileName: manifestFileName(state.id),
|
|
221
|
-
caption:
|
|
266
|
+
caption: manifestCaption({
|
|
267
|
+
id: manifest.id,
|
|
268
|
+
name: manifest.name,
|
|
269
|
+
size: manifest.size,
|
|
270
|
+
chunks: manifest.chunks.length,
|
|
271
|
+
createdAt: manifest.createdAt,
|
|
272
|
+
}),
|
|
222
273
|
})
|
|
223
274
|
|
|
224
275
|
await clearState(key, configDir)
|
|
@@ -227,11 +278,8 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
227
278
|
|
|
228
279
|
return { id: state.id, chunks: chunks.length }
|
|
229
280
|
} finally {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} catch (err) {
|
|
234
|
-
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`)
|
|
235
|
-
}
|
|
281
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
282
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
283
|
+
)
|
|
236
284
|
}
|
|
237
285
|
}
|
package/src/config.js
CHANGED
|
@@ -26,16 +26,21 @@ export async function loadConfig(dir = defaultConfigDir()) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Config and state are both small JSON files holding something that must survive a crash
|
|
30
|
+
// mid-write: write beside the target, then rename, which is atomic on the same filesystem.
|
|
31
|
+
export async function writeJsonAtomic(file, value) {
|
|
32
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
31
33
|
|
|
32
|
-
const file = path.join(dir, FILE_NAME)
|
|
33
34
|
const tmp = `${file}.tmp`
|
|
34
35
|
|
|
35
|
-
await fs.writeFile(tmp, `${JSON.stringify(
|
|
36
|
+
await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
36
37
|
await fs.rename(tmp, file)
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
export async function saveConfig(config, dir = defaultConfigDir()) {
|
|
41
|
+
await writeJsonAtomic(path.join(dir, FILE_NAME), config)
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
export async function clearSession(dir = defaultConfigDir()) {
|
|
40
45
|
const config = await loadConfig(dir)
|
|
41
46
|
delete config.session
|
package/src/downloader.js
CHANGED
|
@@ -2,7 +2,10 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { write as writeCallback } from 'node:fs'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
|
|
5
|
+
import { returnBigInt } from 'telegram/Helpers.js'
|
|
6
|
+
|
|
5
7
|
import { PART_SIZE } from './chunking.js'
|
|
8
|
+
import { withRetry } from './retry.js'
|
|
6
9
|
|
|
7
10
|
const write = promisify(writeCallback)
|
|
8
11
|
|
|
@@ -15,7 +18,7 @@ async function writeExactly(fd, buffer, position) {
|
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export async function downloadToFile(client, message, fd, { offset, onProgress } = {}) {
|
|
21
|
+
export async function downloadToFile(client, message, fd, { offset, onProgress, retryOptions } = {}) {
|
|
19
22
|
const document = message?.media?.document
|
|
20
23
|
|
|
21
24
|
if (!document) {
|
|
@@ -29,11 +32,44 @@ export async function downloadToFile(client, message, fd, { offset, onProgress }
|
|
|
29
32
|
const hash = createHash('sha256')
|
|
30
33
|
let written = 0
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// A chunk is thousands of separate part requests and any one of them can come back
|
|
36
|
+
// -503, so a stream that breaks has to be picked up rather than abandoned — there is no
|
|
37
|
+
// resume file for a download, and giving up throws away every byte fetched so far.
|
|
38
|
+
// Restarting the iterator at `offset + written` is what makes that safe: bytes are
|
|
39
|
+
// hashed in the order they are written and `written` only moves once a buffer has been
|
|
40
|
+
// both written and hashed, so the resumed stream continues the same digest.
|
|
41
|
+
//
|
|
42
|
+
// The two offsets are not the same number and must not be confused: `written` is a
|
|
43
|
+
// position inside the document, which is where the stream resumes, while `offset +
|
|
44
|
+
// written` is a position inside the file being assembled, which is where the bytes land.
|
|
45
|
+
// Every chunk after the first has a non-zero `offset`, so mixing them up reads the wrong
|
|
46
|
+
// part of the document — caught by sha256, but only after re-downloading the whole chunk.
|
|
47
|
+
async function streamFromWhereWeStopped() {
|
|
48
|
+
for await (const buffer of client.iterDownload({
|
|
49
|
+
file: message.media,
|
|
50
|
+
offset: returnBigInt(written),
|
|
51
|
+
requestSize: PART_SIZE,
|
|
52
|
+
})) {
|
|
53
|
+
await writeExactly(fd, buffer, offset + written)
|
|
54
|
+
hash.update(buffer)
|
|
55
|
+
written += buffer.length
|
|
56
|
+
onProgress?.(buffer.length)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One attempt budget for the whole chunk would be the wrong unit: 1800MB is 3600 requests,
|
|
61
|
+
// and five failures spread across them is a healthy download, not a broken one. So a budget
|
|
62
|
+
// only ends the restore when it is spent without gaining a single byte. Every outer pass
|
|
63
|
+
// must gain at least one byte to earn another, which is what bounds the loop.
|
|
64
|
+
for (;;) {
|
|
65
|
+
const before = written
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await withRetry(streamFromWhereWeStopped, retryOptions)
|
|
69
|
+
break
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (written === before) throw err
|
|
72
|
+
}
|
|
37
73
|
}
|
|
38
74
|
|
|
39
75
|
return { sha256: hash.digest('hex'), size: written }
|
package/src/manifest.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import { countChunks } from './chunking.js'
|
|
4
|
+
|
|
3
5
|
export const MANIFEST_VERSION = 1
|
|
4
6
|
|
|
5
7
|
export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).toString('hex')) {
|
|
@@ -61,7 +63,7 @@ export function parseManifest(input) {
|
|
|
61
63
|
}
|
|
62
64
|
})
|
|
63
65
|
|
|
64
|
-
const expectedChunks =
|
|
66
|
+
const expectedChunks = countChunks(manifest.size, manifest.chunkSize)
|
|
65
67
|
if (manifest.chunks.length !== expectedChunks) {
|
|
66
68
|
throw new Error(`Manifest is missing ${expectedChunks - manifest.chunks.length} chunk(s).`)
|
|
67
69
|
}
|
package/src/progress.js
CHANGED
|
@@ -52,9 +52,15 @@ export function createProgress({
|
|
|
52
52
|
const startedAt = now()
|
|
53
53
|
let done = 0
|
|
54
54
|
let lastDrawnAt = startedAt
|
|
55
|
+
let widestLine = 0
|
|
55
56
|
|
|
57
|
+
// \r only moves the cursor home, it does not erase. A redraw that is shorter than the one
|
|
58
|
+
// before it (a shrinking ETA, a speed that changes unit) would leave the previous tail on
|
|
59
|
+
// screen, so pad every line out to the widest one drawn so far.
|
|
56
60
|
function draw(suffix) {
|
|
57
|
-
|
|
61
|
+
const line = renderProgress({ done, total, elapsedMs: now() - startedAt, label })
|
|
62
|
+
widestLine = Math.max(widestLine, line.length)
|
|
63
|
+
write(`\r${line.padEnd(widestLine)}${suffix}`)
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
return {
|