@nightowne/tas-cli 1.1.1 → 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/src/db/index.js CHANGED
@@ -92,6 +92,22 @@ export class FileIndex {
92
92
  );
93
93
  `);
94
94
 
95
+ // Create shares table for temporary file sharing
96
+ this.db.exec(`
97
+ CREATE TABLE IF NOT EXISTS shares (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ file_id INTEGER NOT NULL,
100
+ token TEXT UNIQUE NOT NULL,
101
+ expires_at TEXT NOT NULL,
102
+ max_downloads INTEGER NOT NULL DEFAULT 1,
103
+ download_count INTEGER NOT NULL DEFAULT 0,
104
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
105
+ FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
106
+ );
107
+
108
+ CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(token);
109
+ `);
110
+
95
111
  // Create pending_uploads table for resume functionality
96
112
  this.db.exec(`
97
113
  CREATE TABLE IF NOT EXISTS pending_uploads (
@@ -492,6 +508,76 @@ export class FileIndex {
492
508
  return stmt.get(hash);
493
509
  }
494
510
 
511
+ // ============== SHARE METHODS ==============
512
+
513
+ /**
514
+ * Create a share link for a file
515
+ */
516
+ addShare(fileId, token, expiresAt, maxDownloads = 1) {
517
+ const stmt = this.db.prepare(`
518
+ INSERT INTO shares (file_id, token, expires_at, max_downloads)
519
+ VALUES (?, ?, ?, ?)
520
+ `);
521
+ const result = stmt.run(fileId, token, expiresAt, maxDownloads);
522
+ return result.lastInsertRowid;
523
+ }
524
+
525
+ /**
526
+ * Get a share by token (returns null if not found)
527
+ */
528
+ getShare(token) {
529
+ const stmt = this.db.prepare(`
530
+ SELECT s.*, f.filename, f.original_size
531
+ FROM shares s
532
+ JOIN files f ON s.file_id = f.id
533
+ WHERE s.token = ?
534
+ `);
535
+ return stmt.get(token) || null;
536
+ }
537
+
538
+ /**
539
+ * List all active shares
540
+ */
541
+ listShares() {
542
+ const stmt = this.db.prepare(`
543
+ SELECT s.*, f.filename, f.original_size
544
+ FROM shares s
545
+ JOIN files f ON s.file_id = f.id
546
+ ORDER BY s.created_at DESC
547
+ `);
548
+ return stmt.all();
549
+ }
550
+
551
+ /**
552
+ * Revoke (delete) a share by token
553
+ */
554
+ revokeShare(token) {
555
+ const stmt = this.db.prepare('DELETE FROM shares WHERE token = ?');
556
+ const result = stmt.run(token);
557
+ return result.changes > 0;
558
+ }
559
+
560
+ /**
561
+ * Increment download count for a share
562
+ */
563
+ incrementShareDownload(token) {
564
+ const stmt = this.db.prepare(`
565
+ UPDATE shares SET download_count = download_count + 1 WHERE token = ?
566
+ `);
567
+ stmt.run(token);
568
+ }
569
+
570
+ /**
571
+ * Remove expired shares
572
+ */
573
+ cleanExpiredShares() {
574
+ const stmt = this.db.prepare(`
575
+ DELETE FROM shares WHERE
576
+ REPLACE(REPLACE(expires_at, 'T', ' '), 'Z', '') < strftime('%Y-%m-%d %H:%M:%f', 'now')
577
+ `);
578
+ return stmt.run().changes;
579
+ }
580
+
495
581
  /**
496
582
  * Close database connection
497
583
  */
package/src/fuse/mount.js CHANGED
@@ -130,26 +130,35 @@ export class TelegramFS {
130
130
  }
131
131
 
132
132
  /**
133
- * Read file contents
133
+ * Read file contents from disk cache
134
134
  */
135
135
  async read(filepath, fd, buffer, length, position, cb) {
136
136
  const filename = path.basename(filepath);
137
137
 
138
138
  try {
139
+ // Check write buffers first
140
+ const wb = this.writeBuffers.get(filename);
141
+ if (wb) {
142
+ const slice = wb.data.subarray(position, position + length);
143
+ slice.copy(buffer);
144
+ return cb(slice.length);
145
+ }
146
+
139
147
  // Check cache first
140
- let data = this.getCached(filename);
148
+ let cachedPath = this.getCached(filename);
141
149
 
142
- if (!data) {
143
- // Download and decrypt
144
- data = await this.downloadFile(filename);
145
- this.setCache(filename, data);
150
+ if (!cachedPath) {
151
+ // Download, decrypt, and save to disk cache
152
+ cachedPath = await this.downloadFileToCache(filename);
153
+ this.setCache(filename, cachedPath);
146
154
  }
147
155
 
148
- // Copy requested portion to buffer
149
- const slice = data.subarray(position, position + length);
150
- slice.copy(buffer);
156
+ // Copy requested portion to buffer from disk
157
+ const fdDisk = fs.openSync(cachedPath, 'r');
158
+ const bytesRead = fs.readSync(fdDisk, buffer, 0, length, position);
159
+ fs.closeSync(fdDisk);
151
160
 
152
- return cb(slice.length);
161
+ return cb(bytesRead);
153
162
  } catch (err) {
154
163
  console.error('Read error:', err.message);
155
164
  return cb(Fuse.EIO);
@@ -305,10 +314,10 @@ export class TelegramFS {
305
314
 
306
315
  // Get or load into write buffer
307
316
  if (!this.writeBuffers.has(filename)) {
308
- const cached = this.getCached(filename);
309
- if (cached) {
317
+ const cachedPath = this.getCached(filename);
318
+ if (cachedPath) {
310
319
  this.writeBuffers.set(filename, {
311
- data: Buffer.from(cached),
320
+ data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
312
321
  modified: true
313
322
  });
314
323
  } else {
@@ -335,38 +344,81 @@ export class TelegramFS {
335
344
 
336
345
  // ============== Helper Methods ==============
337
346
 
338
- async downloadFile(filename) {
347
+ async downloadFileToCache(filename) {
339
348
  const file = this.db.findByName(filename);
340
349
  if (!file) throw new Error('File not found');
341
350
 
351
+ const cacheDir = path.join(this.dataDir, 'cache');
352
+ if (!fs.existsSync(cacheDir)) {
353
+ fs.mkdirSync(cacheDir, { recursive: true });
354
+ }
355
+
356
+ const outputPath = path.join(cacheDir, file.hash);
357
+
358
+ // If it's already fully downloaded and cached on disk, return path
359
+ if (fs.existsSync(outputPath)) {
360
+ const stats = fs.statSync(outputPath);
361
+ if (stats.size === file.original_size) {
362
+ return outputPath;
363
+ }
364
+ }
365
+
342
366
  const chunks = this.db.getChunks(file.id);
343
367
  if (chunks.length === 0) throw new Error('No chunks found');
344
368
 
345
- // Download all chunks
346
- const downloadedChunks = [];
347
-
348
- for (const chunk of chunks) {
349
- const data = await this.client.downloadFile(chunk.file_telegram_id);
350
- const header = parseHeader(data);
351
- const payload = data.subarray(HEADER_SIZE);
369
+ // Pre-sort chunks
370
+ chunks.sort((a, b) => a.chunk_index - b.chunk_index);
371
+
372
+ const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
373
+ const header = parseHeader(firstChunkData);
374
+ let wasCompressed = header.compressed;
375
+
376
+ const decryptStream = this.encryptor.getDecryptStream();
377
+ const decompressStream = this.compressor.getDecompressStream(wasCompressed);
378
+
379
+ const { Readable } = await import('stream');
380
+ const { pipeline } = await import('stream/promises');
381
+
382
+ const self = this;
383
+ let currentChunkIndex = 0;
384
+ let preloadedFirstChunk = firstChunkData;
385
+
386
+ const downloadStream = new Readable({
387
+ async read() {
388
+ try {
389
+ if (currentChunkIndex >= chunks.length) {
390
+ this.push(null);
391
+ return;
392
+ }
393
+
394
+ const chunk = chunks[currentChunkIndex];
395
+ let data;
396
+ if (currentChunkIndex === 0 && preloadedFirstChunk) {
397
+ data = preloadedFirstChunk;
398
+ preloadedFirstChunk = null;
399
+ } else {
400
+ data = await self.client.downloadFile(chunk.file_telegram_id);
401
+ }
402
+
403
+ const payload = data.subarray(HEADER_SIZE);
404
+ this.push(payload);
405
+ currentChunkIndex++;
406
+ } catch (err) {
407
+ this.destroy(err);
408
+ }
409
+ }
410
+ });
352
411
 
353
- downloadedChunks.push({
354
- index: header.chunkIndex,
355
- data: payload,
356
- compressed: header.compressed
357
- });
358
- }
412
+ const tmpOutputPath = outputPath + '.tmp';
413
+ const writeStream = fs.createWriteStream(tmpOutputPath);
359
414
 
360
- // Reassemble
361
- downloadedChunks.sort((a, b) => a.index - b.index);
362
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
415
+ // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
416
+ await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
363
417
 
364
- // Decrypt
365
- const compressedData = this.encryptor.decrypt(encryptedData);
418
+ // Rename to final atomic path
419
+ fs.renameSync(tmpOutputPath, outputPath);
366
420
 
367
- // Decompress
368
- const wasCompressed = downloadedChunks[0].compressed;
369
- return await this.compressor.decompress(compressedData, wasCompressed);
421
+ return outputPath;
370
422
  }
371
423
 
372
424
  async uploadFile(filename, data) {
@@ -399,7 +451,7 @@ export class TelegramFS {
399
451
  const encryptedData = this.encryptor.encrypt(compressedData);
400
452
 
401
453
  // Create temp file with header
402
- const tempDir = path.join(this.dataDir, 'tmp');
454
+ const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
403
455
  if (!fs.existsSync(tempDir)) {
404
456
  fs.mkdirSync(tempDir, { recursive: true });
405
457
  }
@@ -438,22 +490,34 @@ export class TelegramFS {
438
490
  if (!entry) return null;
439
491
 
440
492
  if (Date.now() - entry.timestamp > CACHE_TTL) {
493
+ // Expired, delete the file if possible
494
+ try {
495
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
496
+ } catch (e) { }
441
497
  fileCache.delete(filename);
442
498
  return null;
443
499
  }
444
500
 
445
- return entry.data;
501
+ // Extend cache TTL on read
502
+ entry.timestamp = Date.now();
503
+ return entry.path;
446
504
  }
447
505
 
448
- setCache(filename, data) {
506
+ setCache(filename, cachePath) {
449
507
  fileCache.set(filename, {
450
- data,
508
+ path: cachePath,
451
509
  timestamp: Date.now()
452
510
  });
453
511
  }
454
512
 
455
513
  invalidateCache(filename) {
456
- fileCache.delete(filename);
514
+ const entry = fileCache.get(filename);
515
+ if (entry) {
516
+ try {
517
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
518
+ } catch (e) { }
519
+ fileCache.delete(filename);
520
+ }
457
521
  }
458
522
 
459
523
  /**
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 fileData = fs.readFileSync(filePath);
29
- const originalSize = fileData.length;
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
- // Compress
45
- onProgress?.('Compressing...');
45
+ // Prepare processing components
46
46
  const compressor = new Compressor();
47
- const { data: compressedData, compressed } = await compressor.compress(fileData, filename);
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 encryptedData = encryptor.encrypt(compressedData);
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
- // Prepare files with headers
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
- // Upload chunks
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: encryptedData.length,
108
- chunks: chunks.length,
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
- const totalBytes = chunkFiles.reduce((acc, c) => acc + c.size, 0);
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
- for (const chunk of chunkFiles) {
116
- onProgress?.(`Uploading chunk ${chunk.index + 1}/${chunkFiles.length}...`);
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 = chunks.length > 1
119
- ? `📦 ${filename} (${chunk.index + 1}/${chunks.length})`
110
+ const caption = totalChunks > 1
111
+ ? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
120
112
  : `📦 ${filename}`;
121
113
 
122
- const result = await client.sendFile(chunk.path, caption);
114
+ onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
115
+
116
+ const result = await client.sendFile(chunkPath, caption);
123
117
 
124
- uploadedBytes += chunk.size;
125
- onByteProgress?.({ uploaded: uploadedBytes, total: totalBytes, chunk: chunk.index + 1, totalChunks: chunkFiles.length });
118
+ uploadedBytes += chunkData.length;
119
+ totalStoredSize += currentChunkBuffer.length;
126
120
 
127
- // Store file_id instead of message_id for downloads
128
- db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
121
+ onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
129
122
 
130
- // Also store file_id for easier retrieval
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, chunk.index);
126
+ .run(result.fileId, fileId, chunkIndex);
133
127
 
134
- // Clean up temp file
135
- fs.unlinkSync(chunk.path);
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: encryptedData.length,
152
- chunks: chunks.length,
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
- // Download all chunks
182
- const downloadedChunks = [];
183
- let downloadedBytes = 0;
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
- for (const chunk of chunks) {
187
- onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
230
+ // Total original uncompressed size
231
+ let expectedOriginalSize = header.originalSize;
232
+ let wasCompressed = header.compressed;
188
233
 
189
- const data = await client.downloadFile(chunk.file_telegram_id);
190
- downloadedBytes += data.length;
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
- // Parse header
194
- const header = parseHeader(data);
195
- const payload = data.subarray(HEADER_SIZE);
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
- downloadedChunks.push({
198
- index: header.chunkIndex,
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
- // Reassemble
206
- onProgress?.('Reassembling...');
207
- downloadedChunks.sort((a, b) => a.index - b.index);
208
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
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
- // Decrypt
211
- onProgress?.('Decrypting...');
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
- // Decompress
216
- onProgress?.('Decompressing...');
217
- const compressor = new Compressor();
218
- const wasCompressed = downloadedChunks[0].compressed;
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
- // Write to output
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: originalData.length
297
+ size: finalStats.size
228
298
  };
229
299
  }