@nightowne/tas-cli 2.0.0 → 2.1.0
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 +92 -113
- package/package.json +1 -1
- package/src/cli.js +20 -1
- package/src/crypto/encryption.js +128 -0
- package/src/fuse/mount.js +104 -40
- package/src/index.js +173 -103
- package/src/share/server.js +56 -27
- package/src/sync/sync.js +54 -35
- package/src/telegram/client.js +45 -17
- package/src/utils/chunker.js +11 -1
- package/src/utils/compression.js +30 -0
- package/src/utils/throttle.js +26 -0
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
import { pipeline } from 'stream/promises';
|
|
8
9
|
import { Encryptor, hashFile } from './crypto/encryption.js';
|
|
9
10
|
import { Compressor } from './utils/compression.js';
|
|
10
11
|
import { Chunker, createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
|
|
@@ -23,10 +24,10 @@ export async function processFile(filePath, options) {
|
|
|
23
24
|
|
|
24
25
|
onProgress?.('Reading file...');
|
|
25
26
|
|
|
26
|
-
// Read file
|
|
27
|
+
// Read file initially just to get size
|
|
27
28
|
const filename = customName || path.basename(filePath);
|
|
28
|
-
const
|
|
29
|
-
const originalSize =
|
|
29
|
+
const stats = fs.statSync(filePath);
|
|
30
|
+
const originalSize = stats.size;
|
|
30
31
|
|
|
31
32
|
// Calculate hash
|
|
32
33
|
onProgress?.('Calculating hash...');
|
|
@@ -41,99 +42,134 @@ export async function processFile(filePath, options) {
|
|
|
41
42
|
throw new Error('File already uploaded (duplicate hash)');
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
//
|
|
45
|
-
onProgress?.('Compressing...');
|
|
45
|
+
// Prepare processing components
|
|
46
46
|
const compressor = new Compressor();
|
|
47
|
-
const {
|
|
47
|
+
const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
|
|
48
|
+
const flags = compressed ? 1 : 0;
|
|
48
49
|
|
|
49
|
-
// Encrypt
|
|
50
|
-
onProgress?.('Encrypting...');
|
|
51
50
|
const encryptor = new Encryptor(password);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
// Chunk if needed (Telegram bot limit ~50MB per file)
|
|
55
|
-
onProgress?.('Preparing chunks...');
|
|
56
|
-
const chunks = [];
|
|
57
|
-
const numChunks = Math.ceil(encryptedData.length / TELEGRAM_CHUNK_SIZE);
|
|
58
|
-
|
|
59
|
-
for (let i = 0; i < numChunks; i++) {
|
|
60
|
-
const start = i * TELEGRAM_CHUNK_SIZE;
|
|
61
|
-
const end = Math.min(start + TELEGRAM_CHUNK_SIZE, encryptedData.length);
|
|
62
|
-
chunks.push({
|
|
63
|
-
index: i,
|
|
64
|
-
total: numChunks,
|
|
65
|
-
data: encryptedData.subarray(start, end)
|
|
66
|
-
});
|
|
67
|
-
}
|
|
51
|
+
const encryptStream = encryptor.getEncryptStream();
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
const tempDir = path.join(dataDir, 'tmp');
|
|
53
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
71
54
|
if (!fs.existsSync(tempDir)) {
|
|
72
55
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
const chunkFiles = [];
|
|
76
|
-
const flags = compressed ? 1 : 0;
|
|
77
|
-
|
|
78
|
-
for (const chunk of chunks) {
|
|
79
|
-
const header = createHeader(filename, originalSize, chunk.index, chunk.total, flags);
|
|
80
|
-
const chunkData = Buffer.concat([header, chunk.data]);
|
|
81
|
-
|
|
82
|
-
const chunkFilename = chunks.length > 1
|
|
83
|
-
? `${hash.substring(0, 12)}.part${chunk.index}.tas`
|
|
84
|
-
: `${hash.substring(0, 12)}.tas`;
|
|
85
|
-
|
|
86
|
-
const chunkPath = path.join(tempDir, chunkFilename);
|
|
87
|
-
fs.writeFileSync(chunkPath, chunkData);
|
|
88
|
-
|
|
89
|
-
chunkFiles.push({
|
|
90
|
-
index: chunk.index,
|
|
91
|
-
path: chunkPath,
|
|
92
|
-
size: chunkData.length
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
58
|
// Connect to Telegram
|
|
97
59
|
onProgress?.('Connecting to Telegram...');
|
|
98
60
|
const client = new TelegramClient(dataDir);
|
|
99
61
|
await client.initialize(config.botToken);
|
|
100
62
|
client.setChatId(config.chatId);
|
|
101
63
|
|
|
102
|
-
//
|
|
64
|
+
// We will stream through a custom Writable chunker
|
|
65
|
+
const { Writable } = await import('stream');
|
|
66
|
+
|
|
67
|
+
// First pass estimation (for calculating total chunks and progress)
|
|
68
|
+
// We don't know the exact final size due to compression and encryption overhead,
|
|
69
|
+
// so we'll estimate total chunks and update it if needed.
|
|
70
|
+
// For small files < 49MB we assume 1 chunk.
|
|
71
|
+
let estimatedSize = compressed ? originalSize : originalSize + 128; // Add encryption overhead
|
|
72
|
+
if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
|
|
73
|
+
let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
|
|
74
|
+
|
|
75
|
+
// Register file in DB
|
|
103
76
|
const fileId = db.addFile({
|
|
104
77
|
filename,
|
|
105
78
|
hash,
|
|
106
79
|
originalSize,
|
|
107
|
-
storedSize:
|
|
108
|
-
chunks:
|
|
80
|
+
storedSize: 0, // Will update later
|
|
81
|
+
chunks: estimatedChunks,
|
|
109
82
|
compressed
|
|
110
83
|
});
|
|
111
84
|
|
|
85
|
+
onProgress?.('Processing and uploading streams...');
|
|
112
86
|
let uploadedBytes = 0;
|
|
113
|
-
|
|
87
|
+
let chunkIndex = 0;
|
|
88
|
+
|
|
89
|
+
let currentChunkBuffer = Buffer.alloc(0);
|
|
90
|
+
let totalStoredSize = 0;
|
|
91
|
+
|
|
92
|
+
// Helper to upload a single chunk
|
|
93
|
+
const uploadCurrentChunk = async (isFinal = false) => {
|
|
94
|
+
if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
|
|
95
|
+
if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
|
|
96
|
+
|
|
97
|
+
// At this point we know if it's the final chunk, so we know the total chunks
|
|
98
|
+
const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
|
|
99
|
+
|
|
100
|
+
const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
|
|
101
|
+
const chunkData = Buffer.concat([header, currentChunkBuffer]);
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
const chunkFilename = totalChunks > 1
|
|
104
|
+
? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
|
|
105
|
+
: `${hash.substring(0, 12)}.tas`;
|
|
106
|
+
|
|
107
|
+
const chunkPath = path.join(tempDir, chunkFilename);
|
|
108
|
+
fs.writeFileSync(chunkPath, chunkData);
|
|
117
109
|
|
|
118
|
-
const caption =
|
|
119
|
-
? `📦 ${filename} (${
|
|
110
|
+
const caption = totalChunks > 1
|
|
111
|
+
? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
|
|
120
112
|
: `📦 ${filename}`;
|
|
121
113
|
|
|
122
|
-
|
|
114
|
+
onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
|
|
115
|
+
|
|
116
|
+
const result = await client.sendFile(chunkPath, caption);
|
|
123
117
|
|
|
124
|
-
uploadedBytes +=
|
|
125
|
-
|
|
118
|
+
uploadedBytes += chunkData.length;
|
|
119
|
+
totalStoredSize += currentChunkBuffer.length;
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
|
|
121
|
+
onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
|
|
129
122
|
|
|
130
|
-
//
|
|
123
|
+
// Store file_id
|
|
124
|
+
db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
|
|
131
125
|
db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
132
|
-
.run(result.fileId, fileId,
|
|
126
|
+
.run(result.fileId, fileId, chunkIndex);
|
|
133
127
|
|
|
134
|
-
// Clean up temp file
|
|
135
|
-
fs.unlinkSync(
|
|
136
|
-
|
|
128
|
+
// Clean up temp file immediately to save disk space
|
|
129
|
+
fs.unlinkSync(chunkPath);
|
|
130
|
+
|
|
131
|
+
chunkIndex++;
|
|
132
|
+
currentChunkBuffer = Buffer.alloc(0);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const chunkingStream = new Writable({
|
|
136
|
+
async write(chunk, encoding, callback) {
|
|
137
|
+
currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
|
|
138
|
+
|
|
139
|
+
// If we exceeded the chunk limit, flush it
|
|
140
|
+
if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
|
|
141
|
+
const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
|
|
142
|
+
currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await uploadCurrentChunk(false);
|
|
146
|
+
currentChunkBuffer = overflow; // carry over
|
|
147
|
+
callback();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
callback(err);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
callback();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
async final(callback) {
|
|
156
|
+
try {
|
|
157
|
+
await uploadCurrentChunk(true);
|
|
158
|
+
callback();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
callback(err);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const readStream = fs.createReadStream(filePath);
|
|
166
|
+
|
|
167
|
+
// Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
|
|
168
|
+
await pipeline(readStream, compressStream, encryptStream, chunkingStream);
|
|
169
|
+
|
|
170
|
+
// Update the DB with the final accurate values
|
|
171
|
+
db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
|
|
172
|
+
.run(totalStoredSize, chunkIndex, fileId);
|
|
137
173
|
|
|
138
174
|
db.close();
|
|
139
175
|
|
|
@@ -148,8 +184,8 @@ export async function processFile(filePath, options) {
|
|
|
148
184
|
filename,
|
|
149
185
|
hash,
|
|
150
186
|
originalSize,
|
|
151
|
-
storedSize:
|
|
152
|
-
chunks:
|
|
187
|
+
storedSize: totalStoredSize,
|
|
188
|
+
chunks: chunkIndex,
|
|
153
189
|
compressed
|
|
154
190
|
};
|
|
155
191
|
}
|
|
@@ -173,57 +209,91 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
173
209
|
throw new Error('No chunk metadata found for this file');
|
|
174
210
|
}
|
|
175
211
|
|
|
212
|
+
// Prepare components
|
|
213
|
+
const encryptor = new Encryptor(password);
|
|
214
|
+
const decryptStream = encryptor.getDecryptStream();
|
|
215
|
+
|
|
216
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
217
|
+
if (!fs.existsSync(tempDir)) {
|
|
218
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
176
221
|
// Connect to Telegram
|
|
177
222
|
const client = new TelegramClient(dataDir);
|
|
178
223
|
await client.initialize(config.botToken);
|
|
179
224
|
client.setChatId(config.chatId);
|
|
180
225
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
226
|
+
// Get total size from first chunk's header, or from DB
|
|
227
|
+
const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
|
|
228
|
+
const header = parseHeader(firstChunkData);
|
|
185
229
|
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
// Total original uncompressed size
|
|
231
|
+
let expectedOriginalSize = header.originalSize;
|
|
232
|
+
let wasCompressed = header.compressed;
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
234
|
+
const compressor = new Compressor();
|
|
235
|
+
const decompressStream = compressor.getDecompressStream(wasCompressed);
|
|
192
236
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
237
|
+
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
238
|
+
// and push them into the decryption pipeline.
|
|
239
|
+
const { Readable } = await import('stream');
|
|
196
240
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
total: header.totalChunks,
|
|
200
|
-
data: payload,
|
|
201
|
-
compressed: header.compressed
|
|
202
|
-
});
|
|
203
|
-
}
|
|
241
|
+
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
242
|
+
let downloadedBytes = 0;
|
|
204
243
|
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
245
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
246
|
+
|
|
247
|
+
let currentChunkIndex = 0;
|
|
248
|
+
|
|
249
|
+
// We already downloaded the first chunk to inspect its header, we shouldn't discard it.
|
|
250
|
+
let preloadedFirstChunk = firstChunkData;
|
|
251
|
+
|
|
252
|
+
const downloadStream = new Readable({
|
|
253
|
+
async read() {
|
|
254
|
+
try {
|
|
255
|
+
if (currentChunkIndex >= chunks.length) {
|
|
256
|
+
this.push(null); // End of stream
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const chunk = chunks[currentChunkIndex];
|
|
261
|
+
onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
|
|
262
|
+
|
|
263
|
+
let data;
|
|
264
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
265
|
+
data = preloadedFirstChunk;
|
|
266
|
+
preloadedFirstChunk = null;
|
|
267
|
+
} else {
|
|
268
|
+
data = await client.downloadFile(chunk.file_telegram_id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
downloadedBytes += data.length;
|
|
272
|
+
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
273
|
+
|
|
274
|
+
// Strip header before pushing
|
|
275
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
276
|
+
this.push(payload);
|
|
277
|
+
|
|
278
|
+
currentChunkIndex++;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.destroy(err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
209
284
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const encryptor = new Encryptor(password);
|
|
213
|
-
const compressedData = encryptor.decrypt(encryptedData);
|
|
285
|
+
const writeStream = fs.createWriteStream(outputPath);
|
|
286
|
+
const { pipeline } = await import('stream/promises');
|
|
214
287
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const originalData = await compressor.decompress(compressedData, wasCompressed);
|
|
288
|
+
onProgress?.('Decrypting, decompressing, and writing file...');
|
|
289
|
+
|
|
290
|
+
// Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
|
|
291
|
+
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
220
292
|
|
|
221
|
-
|
|
222
|
-
onProgress?.('Writing file...');
|
|
223
|
-
fs.writeFileSync(outputPath, originalData);
|
|
293
|
+
const finalStats = fs.statSync(outputPath);
|
|
224
294
|
|
|
225
295
|
return {
|
|
226
296
|
path: outputPath,
|
|
227
|
-
size:
|
|
297
|
+
size: finalStats.size
|
|
228
298
|
};
|
|
229
299
|
}
|
package/src/share/server.js
CHANGED
|
@@ -241,33 +241,63 @@ export class ShareServer {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
|
-
*
|
|
244
|
+
* Stream a decrypted file from Telegram directly to the HTTP response
|
|
245
245
|
*/
|
|
246
|
-
async
|
|
246
|
+
async streamToResponse(fileRecord, res) {
|
|
247
247
|
const chunks = this.db.getChunks(fileRecord.id);
|
|
248
248
|
if (chunks.length === 0) throw new Error('No chunks found');
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
250
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
251
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
252
|
+
|
|
253
|
+
// Get total size from first chunk's header
|
|
254
|
+
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
255
|
+
const header = parseHeader(firstChunkData);
|
|
256
|
+
let wasCompressed = header.compressed;
|
|
257
|
+
|
|
258
|
+
// Prepare streams
|
|
259
|
+
const decryptStream = this.encryptor.getDecryptStream();
|
|
260
|
+
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
261
|
+
|
|
262
|
+
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
263
|
+
const { Readable } = await import('stream');
|
|
264
|
+
const { pipeline } = await import('stream/promises');
|
|
265
|
+
|
|
266
|
+
const self = this;
|
|
267
|
+
let currentChunkIndex = 0;
|
|
268
|
+
let preloadedFirstChunk = firstChunkData;
|
|
269
|
+
|
|
270
|
+
const downloadStream = new Readable({
|
|
271
|
+
async read() {
|
|
272
|
+
try {
|
|
273
|
+
if (currentChunkIndex >= chunks.length) {
|
|
274
|
+
this.push(null); // End of stream
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const chunk = chunks[currentChunkIndex];
|
|
279
|
+
let data;
|
|
280
|
+
|
|
281
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
282
|
+
data = preloadedFirstChunk;
|
|
283
|
+
preloadedFirstChunk = null;
|
|
284
|
+
} else {
|
|
285
|
+
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Strip header before pushing
|
|
289
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
290
|
+
this.push(payload);
|
|
291
|
+
|
|
292
|
+
currentChunkIndex++;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.destroy(err);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
});
|
|
268
298
|
|
|
269
|
-
|
|
270
|
-
|
|
299
|
+
// Pipeline: Download from Telegram -> Decrypt -> Decompress -> HTTP Response
|
|
300
|
+
await pipeline(downloadStream, decryptStream, decompressStream, res);
|
|
271
301
|
}
|
|
272
302
|
|
|
273
303
|
/**
|
|
@@ -330,9 +360,6 @@ export class ShareServer {
|
|
|
330
360
|
return;
|
|
331
361
|
}
|
|
332
362
|
|
|
333
|
-
// Download the file from Telegram, decrypt, and serve
|
|
334
|
-
const data = await this.downloadAndDecrypt(fileRecord);
|
|
335
|
-
|
|
336
363
|
// Increment download count
|
|
337
364
|
this.db.incrementShareDownload(token);
|
|
338
365
|
|
|
@@ -355,9 +382,11 @@ export class ShareServer {
|
|
|
355
382
|
res.writeHead(200, {
|
|
356
383
|
'Content-Type': contentType,
|
|
357
384
|
'Content-Disposition': `attachment; filename="${fileRecord.filename}"`,
|
|
358
|
-
'Content-Length':
|
|
385
|
+
'Content-Length': fileRecord.original_size
|
|
359
386
|
});
|
|
360
|
-
|
|
387
|
+
|
|
388
|
+
// Download the file from Telegram, decrypt, decompress and stream directly to 'res'
|
|
389
|
+
await this.streamToResponse(fileRecord, res);
|
|
361
390
|
|
|
362
391
|
} catch (err) {
|
|
363
392
|
console.error('Share server error:', err.message);
|
package/src/sync/sync.js
CHANGED
|
@@ -29,6 +29,7 @@ export class SyncEngine extends EventEmitter {
|
|
|
29
29
|
this.dataDir = options.dataDir;
|
|
30
30
|
this.password = options.password;
|
|
31
31
|
this.config = options.config;
|
|
32
|
+
this.limitRate = options.limitRate || null;
|
|
32
33
|
this.watchers = new Map(); // path -> FSWatcher
|
|
33
34
|
this.pendingChanges = new Map(); // path -> timeout
|
|
34
35
|
this.db = null;
|
|
@@ -98,53 +99,70 @@ export class SyncEngine extends EventEmitter {
|
|
|
98
99
|
let uploaded = 0;
|
|
99
100
|
let skipped = 0;
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
// Process files with concurrency limit
|
|
103
|
+
const CONCURRENCY = 4;
|
|
104
|
+
const queue = [...files];
|
|
105
|
+
const promises = [];
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
107
|
+
const worker = async () => {
|
|
108
|
+
while (queue.length > 0) {
|
|
109
|
+
const file = queue.shift();
|
|
110
|
+
const existing = stateMap.get(file.relativePath);
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
// Check if file has changed (by mtime)
|
|
113
|
+
if (existing && existing.mtime >= file.mtime) {
|
|
114
|
+
skipped++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
112
117
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
116
|
-
skipped++;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
118
|
+
// Calculate hash to detect actual changes
|
|
119
|
+
const hash = await hashFile(file.path);
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (existing && existing.file_hash === hash) {
|
|
122
|
+
// File unchanged, just update mtime
|
|
123
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
124
|
+
skipped++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
customName: file.relativePath, // Use relative path as name
|
|
128
|
-
config: this.config,
|
|
129
|
-
onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
|
|
130
|
-
});
|
|
128
|
+
// File is new or changed - upload it
|
|
129
|
+
try {
|
|
130
|
+
this.emit('file-upload-start', { file: file.relativePath });
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
await processFile(file.path, {
|
|
133
|
+
password: this.password,
|
|
134
|
+
dataDir: this.dataDir,
|
|
135
|
+
customName: file.relativePath, // Use relative path as name
|
|
136
|
+
config: this.config,
|
|
137
|
+
limitRate: this.limitRate ? Math.floor(this.limitRate / CONCURRENCY) : null,
|
|
138
|
+
onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
|
|
139
|
+
});
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
} catch (err) {
|
|
138
|
-
// File might already exist, skip
|
|
139
|
-
if (err.message.includes('duplicate')) {
|
|
141
|
+
// Update sync state
|
|
140
142
|
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.emit('file-upload-
|
|
143
|
+
uploaded++;
|
|
144
|
+
|
|
145
|
+
this.emit('file-upload-complete', { file: file.relativePath });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// File might already exist, skip
|
|
148
|
+
if (err.message.includes('duplicate')) {
|
|
149
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
150
|
+
skipped++;
|
|
151
|
+
} else {
|
|
152
|
+
// Sleep briefly on non-duplicate error (potential rate limits)
|
|
153
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
154
|
+
this.emit('file-upload-error', { file: file.relativePath, error: err.message });
|
|
155
|
+
}
|
|
144
156
|
}
|
|
145
157
|
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < CONCURRENCY; i++) {
|
|
161
|
+
promises.push(worker());
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
await Promise.all(promises);
|
|
165
|
+
|
|
148
166
|
this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
|
|
149
167
|
|
|
150
168
|
return { uploaded, skipped };
|
|
@@ -206,6 +224,7 @@ export class SyncEngine extends EventEmitter {
|
|
|
206
224
|
dataDir: this.dataDir,
|
|
207
225
|
customName: filename,
|
|
208
226
|
config: this.config,
|
|
227
|
+
limitRate: this.limitRate,
|
|
209
228
|
onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
|
|
210
229
|
});
|
|
211
230
|
|